feat(shared-skills): add user skill sources
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
---
|
||||
name: programming
|
||||
description: "MUST USE for ANY work on .py .pyi .rs .ts .tsx .mts .cts .go files. One philosophy: strict types, modern stacks (Pydantic v2 / serde+thiserror / Zod / gin+sqlc+pgx+slog), modern toolchains (uv+basedpyright+ruff / cargo+clippy+miri / Bun+Biome+tsc / gofumpt+golangci-lint v2+nilaway+go-race), parse-don't-validate, exhaustive match, typed errors, no any/unwrap/panic, 250 LOC ceiling, TDD. Routes to references/{python,rust,typescript,rust-ub,go}/. Triggers: write/edit Python/Rust/TypeScript/Go code, new project, gin server, bubbletea TUI, CJK IME, connect-go RPC, sqlc pgx, branded ids, exhaustive match, unsafe Rust, miri, oversized file, refactor, TDD, e2e test, arena, allocator, bumpalo, const fn, const generics, comptime, zero-alloc, bitfield, repr, scopeguard, errdefer, Zig-like, zerocopy, packed struct."
|
||||
---
|
||||
|
||||
# Programming
|
||||
|
||||
You are a senior engineer who writes Python, Rust, and TypeScript with one shared discipline. **Type-strict. Stack-first. Async-correct. Architecturally honest about file size.**
|
||||
|
||||
This skill is an index. The hard per-language rules live under `references/`. Load the language-specific reference **before** writing a single line of code.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0 — LANGUAGE GATE (RUN THIS FIRST, EVERY TIME)
|
||||
|
||||
**DO NOT WRITE OR EDIT A SINGLE LINE OF CODE BEFORE COMPLETING THIS GATE.**
|
||||
|
||||
1. **Identify the language** from the file extension or the user's request.
|
||||
2. **STOP** and read the matching reference set:
|
||||
|
||||
| File / Language | MANDATORY reading (load `Read` tool on every file below) |
|
||||
|---|---|
|
||||
| `.py`, `.pyi`, "Python" | `references/python/README.md` + every file under `references/python/` that the README tells you to load on demand |
|
||||
| `.rs`, `Cargo.toml`, "Rust" | `references/rust/README.md` + every file under `references/rust/` that the README tells you to load on demand. **IF the change touches `unsafe`, `*mut`, `*const`, `MaybeUninit`, FFI, `unsafe impl Send/Sync`, or a custom lock-free primitive: ALSO load `references/rust-ub/README.md` plus every file under `references/rust-ub/`.** |
|
||||
| `.ts`, `.tsx`, `.mts`, `.cts`, "TypeScript" | `references/typescript/README.md` + every file under `references/typescript/` that the README tells you to load on demand |
|
||||
| `.go`, `go.mod`, `go.sum`, `.golangci.yml`, `*.proto` next to a Go module, "Go" / "Golang" | `references/go/README.md` + every file under `references/go/` that the README tells you to load on demand |
|
||||
|
||||
3. Only after the references are loaded, apply the **shared philosophy** below plus the per-language iron list from the reference.
|
||||
|
||||
**No exceptions for "small" or "one-off" code.** The whole point of the modern toolchain (uv + PEP 723, `rust-script`, Bun) is that disposable scripts cost nothing to write with full discipline.
|
||||
|
||||
---
|
||||
|
||||
## Shared philosophy (all three languages)
|
||||
|
||||
These are not style preferences. They are the six axioms every recipe in `references/` derives from.
|
||||
|
||||
1. **The type system is your proof system.** Make illegal states unrepresentable. The compiler / type checker is the cheapest test you will ever run. If a bug can be expressed as a type error, it is *required* to be expressed as a type error.
|
||||
|
||||
2. **Parse, don't validate.** Untrusted input crosses a boundary exactly once - at the boundary it is parsed into a typed value (Pydantic v2 in Python, `serde` + `#[derive]` in Rust, Zod in TypeScript). Inside the boundary, code receives typed values and never re-validates. The boundary owns trust; the interior owns logic.
|
||||
|
||||
3. **One name = one concept.** A `UserId` is not a `string`. A `Seconds` is not a `Milliseconds`. Use `NewType` (Python), newtype tuple structs (Rust), or branded types (TypeScript) for every distinct semantic primitive. The compiler refuses to let two semantic units mix.
|
||||
|
||||
4. **Exhaustive variant matching, always.** Discriminated unions and enums are matched exhaustively. Python: `match` + `case unreachable: assert_never(unreachable)`. Rust: `match` (the compiler enforces). TypeScript: `switch` + `assertNever`. **`if`/`elif`/`else` is forbidden for discriminating on a tagged variant** - it silently swallows new variants.
|
||||
|
||||
5. **Trust framework guarantees. Validate only at boundaries.** No null checks for values the type system already proves non-null. No `try/except` around code that cannot raise. No `unwrap`/`!`/`as` to paper over a contract you should have encoded in types. No defensive layer for a scenario you cannot name.
|
||||
|
||||
6. **Test-driven, with the right shape of test.** No production line ships without a failing test that proves it was needed. Behavior is locked by tests, not by hope. See the TDD discipline below.
|
||||
|
||||
---
|
||||
|
||||
## TDD DISCIPLINE — NON-NEGOTIABLE
|
||||
|
||||
**Every change follows the red → green → refactor loop.** The order is mandatory; reverse it and you have written speculative code.
|
||||
|
||||
### The order
|
||||
|
||||
1. **Red.** Write a failing test that names the behavior in `Given / When / Then`. Run it. *Confirm it fails for the right reason* — not a typo, not an import error. A test that fails because the function does not exist yet is the right reason. A test that fails because of a missing import is not.
|
||||
2. **Green.** Write the minimum code to make the test pass. Resist adding the second case until the first passes. The second case is the next red.
|
||||
3. **Refactor.** With the test green, restructure ruthlessly. The test is your safety net. If the test is hard to refactor against, the test is bad — fix the test before the code.
|
||||
|
||||
### The shape of the test pyramid
|
||||
|
||||
Every feature ships with all three rungs, sized in this proportion:
|
||||
|
||||
| Rung | Count | Purpose | Speed budget |
|
||||
|---|---|---|---|
|
||||
| **Unit** | many | Pure-function correctness for every meaningful input class (happy + edges + boundaries + error paths) | < 10 ms each |
|
||||
| **Integration** | some | The real adapter against the real downstream (DB, queue, HTTP) — via `testcontainers`, `httptest`, or equivalent. NEVER a unit test pretending to be integration. | < 1 s each |
|
||||
| **E2E scenario** | few | One narrative per user-visible outcome. Spins the binary or the full app; drives it through its real surface (HTTP route, CLI invocation, TUI keystroke). Asserts the *observable outcome*, not internal state. | seconds, run on CI |
|
||||
|
||||
If a feature has zero E2E coverage, it is undone — even if every unit test passes.
|
||||
|
||||
### Given / When / Then is mandatory
|
||||
|
||||
Every test — unit, integration, E2E — is structured by these three blocks. Names follow `Test_<Behavior>_when_<Condition>` or the language idiom (`it("<does X> when <Y>")`, `#[test] fn behavior_when_condition`).
|
||||
|
||||
```
|
||||
Given: the preconditions and fixtures
|
||||
When: the single action under test
|
||||
Then: the observable outcome AND only that outcome
|
||||
```
|
||||
|
||||
One `When` per test. Multiple `When`s = multiple tests. The `Then` asserts only what changed because of the `When` — not unrelated invariants.
|
||||
|
||||
### Less mock, the better
|
||||
|
||||
Mocks are a last resort, not a default. The priority order:
|
||||
|
||||
1. **Real object.** Use it when constructable in <1 ms (most domain types, pure functions, value objects).
|
||||
2. **In-memory fake.** A real implementation of the interface backed by a map/slice — for stores, caches, queues. The fake has its OWN test that proves it behaves like the real one.
|
||||
3. **Testcontainer / sandbox.** Real Postgres, real Redis, real S3-compatible (MinIO), via `testcontainers`. Slow but truthful.
|
||||
4. **HTTP-level fake.** `httptest.Server` (Go), `respx` (Python), `msw` (TS) — fake at the wire, not at the SDK.
|
||||
5. **Mock.** Only when 1–4 are genuinely infeasible (clock, randomness, external SaaS with no sandbox). Then mock the **narrowest** seam — never an entire service. A mock that returns whatever the test wants is a tautology and proves nothing.
|
||||
|
||||
**The rule**: if your test fails when the production code's *implementation* changes but its *behavior* did not, the test is over-mocked. Delete the mock; assert on observable outputs.
|
||||
|
||||
### Efficient AND accurate — both, not either
|
||||
|
||||
- **Accurate**: the test fails for the bug it names, and only that bug. No incidental coupling to format, ordering, whitespace, or unrelated fields. Assert on the *contract*, not on the dump.
|
||||
- **Efficient**: the whole unit suite runs in < 30 seconds on a developer laptop. The whole integration suite in < 5 minutes. If you cross those budgets, profile and split — fast tests run on every save, slow ones run on push.
|
||||
- **Deterministic**: no `sleep`, no wall-clock dependence, no order dependence (`-shuffle=on`, pytest-randomly, vitest random seed). Inject a `Clock`. Subscribe to the event, do not poll for it. Time-based flake is a bug, not a test issue.
|
||||
- **Isolated**: every test starts from a known fixture and tears down. `t.TempDir()`, `t.Setenv()`, transactional rollback for DB tests. Two tests passing individually but failing together is a fixture leak — fix it immediately.
|
||||
|
||||
### Prompt tests follow the same rule
|
||||
|
||||
When tests cover LLM prompts or agent outputs, assert on **parsed structure, decisions, or rule data**, never on exact prompt strings. Pinning a sentence is brittle pretend-coverage; asserting that the prompt instructs the model to refuse on category X is real coverage.
|
||||
|
||||
### Anti-patterns the skill rejects
|
||||
|
||||
| Anti-pattern | Why it fails | Fix |
|
||||
|---|---|---|
|
||||
| Writing code first, tests "to add later" | Tests-after rationalize the existing design, even when wrong. | Red first. Always. |
|
||||
| One mega-test asserting 12 things | First failure hides the next 11. | Split by `Then` clause — one assertion class per test. |
|
||||
| Mocking every collaborator | Test passes regardless of real behavior. | Use a fake or the real thing. Mock only true unmockables. |
|
||||
| `time.sleep(0.1)` to "let it finish" | Flake guaranteed. | Subscribe to the completion signal; bounded await. |
|
||||
| Snapshot tests for everything | Locks formatting, not behavior. | Snapshots for *structure* (CLI help, JSON shape). Assertions for *behavior*. |
|
||||
| Removing a failing test to "unblock CI" | You just deleted a bug report. | Fix the code or fix the test — never delete to silence. |
|
||||
| `assert result is not None` and stopping there | Passes when result is garbage. | Assert the *value*, not its existence. |
|
||||
| Single happy-path E2E, no edges | Most bugs live on edges. | Edges are unit-test territory — but include at least one E2E that exercises an error path. |
|
||||
|
||||
---
|
||||
|
||||
## Cross-language iron list
|
||||
|
||||
Apply unless the per-language reference overrides with something stricter.
|
||||
|
||||
| Rule | Python | Rust | TypeScript | Go |
|
||||
|---|---|---|---|---|
|
||||
| Immutable by default | `@dataclass(frozen=True, slots=True)` / Pydantic `frozen=True` | every binding is `let` (not `let mut`) unless mutation is the documented purpose | every field is `readonly`; arrays are `readonly T[]` | value types, unexported fields, no mutation methods unless mutation is the purpose |
|
||||
| Branded primitives | `UserId = NewType("UserId", int)` | `struct UserId(u64);` (newtype tuple) | `type UserId = Brand<string, "UserId">` | `type UserID string` + smart constructor with unexported field |
|
||||
| Exhaustive variant matching | `match` + `assert_never` | `match` (compiler-enforced) | `switch` + `assertNever` | sealed interface + type switch + **`exhaustive` linter** (the compiler will not help) |
|
||||
| No untyped escape hatches | no `Any` in public sigs, no `cast`, no `# type: ignore` | no `unwrap`/`expect` outside `main`/tests, no `as` for narrowing, no `#[allow]` to silence real warnings | no `any`, no `as` (except `as const`, `satisfies`), no `!`, no `@ts-ignore`, no `@ts-expect-error` | no `interface{}` / bare `any` in domain sigs; no `_ = err`; no `//nolint` without reason |
|
||||
| No bare error strings | typed exception dataclass with `__str__` | `thiserror` enum (lib) or `anyhow` with `.context(...)` (app) | `Error` subclass with typed fields | sentinel `errors.New` + typed `*XError` struct; wrap with `%w`; check via `errors.Is/As` |
|
||||
| Boundary catch only | catch the exact exception you expect; broad `except Exception` only in `main()`, with logging + re-raise | `?` everywhere; never `panic!` in library code | `catch` must narrow with `instanceof` and re-throw or convert; no empty catch | every `(T, error)` checked; `panic` only in `main`/tests; one `httperr.Write` funnel in handlers |
|
||||
| Resources via RAII | `with` (sync) / `async with` (async) | `Drop` impl or RAII guard | `using`/`await using` (TC39 explicit resource management) | `defer x.Close()` immediately after acquisition; `bodyclose`/`sqlclosecheck` linters enforce |
|
||||
| Async runtime is mandatory | `anyio` (NEVER bare `asyncio`) | `tokio` (`async-std` is unmaintained) | platform-native async (Bun/Node) with structured cancellation via `AbortSignal` | `context.Context` as first param + `errgroup` for structured concurrency; `-race` on every test |
|
||||
| Modern HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) with HTTP/2 + brotli + zstd | `reqwest` with rustls | `ky` (default) / `undici` direct API (Node perf) - NEVER bare `fetch` in prod | stdlib `net/http.Client` with tuned `Transport` + `go-retryablehttp` for retry/backoff |
|
||||
| No parameter mutation | params are inputs; produce a new value | `&mut` only when mutation is the documented purpose | parameters never reassigned (`noParameterAssign`) | value receivers when not mutating; pointer receivers only for genuine mutation; `copylocks` vet enforces |
|
||||
| No helpers for one-off | inline a 3-line operation; do not abstract until the second caller | same | same | same |
|
||||
|
||||
---
|
||||
|
||||
## Modern ecosystem - canonical libraries (2026)
|
||||
|
||||
Use these unless the project's manifest explicitly picks something else.
|
||||
|
||||
| Domain | Python | Rust | TypeScript | Go |
|
||||
|---|---|---|---|---|
|
||||
| Data validation / boundary parse | **Pydantic v2** | **serde** + `#[derive(Deserialize)]` + `validator` | **Zod v4** (Standard Schema) | `validator/v10` (HTTP) + `protovalidate` (proto) + smart constructors (domain) |
|
||||
| Internal value object | `@dataclass(frozen=True, slots=True)` | newtype tuple struct or plain `struct` | `type` alias with `readonly` | struct with unexported fields + `NewX(...)` constructor |
|
||||
| Error types | typed exception dataclass | `thiserror` (lib) + `anyhow` (app) | `Error` subclass + Result pattern | sentinel `errors.New` + typed `*XError` struct + `%w` wrap |
|
||||
| HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) | `reqwest` | `ky` / `undici` | stdlib `net/http` + `go-retryablehttp` |
|
||||
| Web framework | **FastAPI** | **axum** | **Hono** + `hono-openapi` | **gin** (de facto, ~48%) / `chi` (minimalist) / `connect-go` (RPC) |
|
||||
| ORM / DB | SQLAlchemy 2.x async + `asyncpg` | `sqlx` (compile-time checked) | **Drizzle** | **sqlc** (codegen from `.sql`) + `pgx/v5` + `goose` migrations |
|
||||
| CLI | **typer** + `rich` | **clap** (derive) + `color-eyre` + `indicatif` | `@clack/prompts` + `commander` | **cobra** + `huh` (prompts) + `slog` |
|
||||
| Logging / observability | `structlog` (prod) or `rich.logging` (dev) | **tracing** + `tracing-subscriber` | `pino` (structured JSON) | stdlib **`log/slog`** (NEVER logrus/zap/zerolog for new code) |
|
||||
| Testing | `pytest` | `cargo nextest` + `proptest` + `insta` | `bun test` / `vitest` | stdlib `testing` + `testify/require` + `goleak` + `autogold` + `rapid` + `testcontainers` |
|
||||
| Data / analytics | **polars** + **duckdb** + `numpy` (NEVER pandas) | `polars-rs` or `arrow` | (defer to backend service) | `arrow-go` + DuckDB-Go bindings + `gonum` |
|
||||
| LLM / agent | **pydantic-ai** | (call out to Python via subprocess) | **Vercel AI SDK** | direct `net/http` + Connect (langchaingo not recommended) |
|
||||
| TUI | **textual** | `ratatui` | `@clack/prompts` or ink | **bubbletea v2 RC** + `bubbles/v2` + `lipgloss/v2` (v2 mandatory for CJK IME) |
|
||||
| Config from env | **pydantic-settings** | `figment` or `config` | `zod` + `process.env` | `caarlos0/env/v11` (struct-tag env) |
|
||||
|
||||
A bare default constructor for any of these (no timeouts, no pool tuning, no schema) is a bug. See the per-language reference for the canonical production defaults.
|
||||
|
||||
---
|
||||
|
||||
## Modern toolchain - the only acceptable setup
|
||||
|
||||
| Tool category | Python | Rust | TypeScript | Go |
|
||||
|---|---|---|---|---|
|
||||
| Package / project manager | **uv** (NEVER pip/poetry/conda) | **cargo** + `cargo-nextest` + `cargo-machete` + `cargo-deny` | **Bun** (runtime + package manager); pnpm if Node is forced | **`go modules`** + `go work` for monorepos |
|
||||
| Type checker | **basedpyright** with `typeCheckingMode = "all"` | the Rust compiler with `-D warnings` + clippy `pedantic` + `nursery` + `cargo` groups | `tsc --noEmit` (or `tsgo` when available) with `strict` + `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` + `verbatimModuleSyntax` | the Go compiler + **`golangci-lint v2`** with the strict bundle + **`nilaway`** (nil-deref static analysis) |
|
||||
| Linter + formatter | **ruff** with `select = ["ALL"]` | `clippy` + `rustfmt` | **Biome** (single binary - replaces ESLint + Prettier) | **`gofumpt`** (stricter gofmt) + `goimports -local` + `golangci-lint v2` |
|
||||
| Test runner | **pytest** | **cargo-nextest** | `bun test` / `vitest` | stdlib `go test -race -shuffle=on -count=1` + `goleak` |
|
||||
| UB / soundness gate | (n/a) | **nightly miri** with strict provenance + Tree Borrows pass | (n/a) | **`nilaway`** + `-race` detector + `goleak` are the equivalent gate |
|
||||
| Disposable scripts | **PEP 723** inline metadata + `uv run script.py` | **rust-script** with inline `Cargo.toml` block | `bun run script.ts` | `//go:build ignore` + `go run script.go` |
|
||||
| Bootstrap a new project | `scripts/python/new-project.py` | `scripts/rust/new-project.py` | `scripts/typescript/new-project.ts` | `scripts/go/new-project.py` |
|
||||
| Pre-commit / CI gate | `ruff check . && basedpyright && pytest` | `cargo +nightly clippy -- -D warnings && cargo nextest run && cargo +nightly miri test` | `bunx biome check . && bunx tsc --noEmit && bun test` | `gofumpt -l . && golangci-lint run ./... && nilaway ./... && go test -race -shuffle=on -count=1 ./...` |
|
||||
|
||||
A `tsconfig.json` with `"strict": true` alone is **not** strict. The reference enumerates the additional flags. Same for `pyproject.toml` and `Cargo.toml` - the references contain the canonical full configuration.
|
||||
|
||||
---
|
||||
|
||||
## THE 250 PURE LOC CEILING (NON-NEGOTIABLE)
|
||||
|
||||
**A source file whose pure LOC (non-blank, non-comment lines) exceeds 250 is architecturally broken.** Not a style preference. Not a soft suggestion. **A defect.**
|
||||
|
||||
A file past this line is telling you, loudly:
|
||||
|
||||
- The module is doing more than one thing.
|
||||
- Multiple cohesive units got merged "to save a file".
|
||||
- Re-exports, barrels, and orchestrators got fused into pure-logic units.
|
||||
- Every future reader pays a tax to find what they need.
|
||||
|
||||
### Why 250 and not 500 or 1000
|
||||
|
||||
At 250 pure LOC a file still fits in one screen on a 32-inch monitor with a 14pt font. A reviewer can hold the whole thing in working memory and spot a cross-cutting bug. At 500 LOC they cannot. At 1000 LOC they stop trying. The number is **the cognitive ceiling of a single human reviewer who has not memorized the file.**
|
||||
|
||||
### Measuring pure LOC
|
||||
|
||||
```bash
|
||||
# Quick (line-comment + blank exclusion - good enough for Python, Rust, TypeScript):
|
||||
awk '!/^[[:space:]]*$/ && !/^[[:space:]]*(\/\/|#|--)/' <file> | wc -l
|
||||
|
||||
# Authoritative (handles block comments correctly):
|
||||
cloc --by-file <file> # the "code" column is the number that matters
|
||||
```
|
||||
|
||||
### Required behavior
|
||||
|
||||
**Creating a file that will exceed 250 pure LOC.** STOP. Split it **before the first commit**. Carve by responsibility (single-responsibility principle), one cohesive unit per file. Use a barrel (`__init__.py`, `mod.rs`, `index.ts`) for re-exports ONLY. **Never** for logic.
|
||||
|
||||
**Editing a file that already exceeds 250 pure LOC and your edit adds lines.** STOP. Refactor the unit you are touching into its own file BEFORE adding the new lines. The split is part of THIS task, not a follow-up someone will never do.
|
||||
|
||||
**Reading a file that exceeds 250 pure LOC while implementing a feature.** Surface the smell explicitly in your reply, propose a concrete split (which functions go where, in 1-2 lines each), and ask the user whether to split now or carry the smell into the feature work. Do not silently keep going.
|
||||
|
||||
### Forbidden escapes
|
||||
|
||||
- Counting comments and blank lines toward the budget. **Pure LOC means code lines.** Period.
|
||||
- Splitting by token count (`foo_1.py`, `module_part_A.rs`, `service-2.ts`). **REJECT.** Split by what each file DOES. Name each file after the concept it owns.
|
||||
- Catch-all dump files: `utils.py`, `helpers.ts`, `lib.rs` (as a logic dump), `common.py`, `shared.ts`. **REJECT.** These just relocate the smell.
|
||||
- "It's generated, so it's fine." Only true if the file lives in `dist/`, `target/`, `__generated__/`, or wherever the build authoritatively rewrites. Hand-edited "I will regenerate it later" files do NOT qualify.
|
||||
- "It's a test file with many cases." Split by SUT or by behavior cluster. One file per cohesive `describe` group.
|
||||
- "230 pure LOC, close enough." A 230-LOC file about to grow is already over the line. Split now. **Do not race to the ceiling.**
|
||||
|
||||
### Acceptable exceptions (rare, require justification)
|
||||
|
||||
A file may legitimately exceed 250 pure LOC if **and only if** it is:
|
||||
|
||||
- A **truly indivisible single-responsibility unit** (e.g., a generated parser table, a state machine whose states share a single closure, a `derive` macro implementation). Mark the first 5 lines with a comment such as `# noqa: SIZE_OK - generated parser table, 612 states share branch tables` (Python) / `// allow: SIZE_OK - state machine, removing any state breaks the transition matrix` (Rust/TS), and explain WHY no split is possible.
|
||||
- A **pure data table** (translation strings, error code lookup, brand color palette). Tables of data are not logic.
|
||||
|
||||
**`# noqa: SIZE_OK` without a justifying comment is itself slop** and must be rejected by the next person to touch the file.
|
||||
|
||||
### Concrete split examples
|
||||
|
||||
#### Python - BEFORE (`user_service.py`, 412 pure LOC, broken)
|
||||
|
||||
```python
|
||||
# user_service.py - DOES TOO MUCH
|
||||
class UserRepository: ... # 90 LOC of SQLAlchemy
|
||||
class UserValidator: ... # 60 LOC of Pydantic + business rules
|
||||
class PasswordHasher: ... # 40 LOC of bcrypt wrapper
|
||||
class EmailSender: ... # 50 LOC of httpx2 client
|
||||
class UserService: ... # 130 LOC orchestrating the four above
|
||||
def _build_query(...): ... # 25 LOC helper
|
||||
def _format_email(...): ... # 17 LOC helper
|
||||
```
|
||||
|
||||
#### Python - AFTER (split by responsibility)
|
||||
|
||||
```
|
||||
src/myapp/users/
|
||||
├── __init__.py # barrel: re-exports UserService only (5 LOC)
|
||||
├── repository.py # UserRepository (~95 LOC)
|
||||
├── validator.py # UserValidator (~65 LOC)
|
||||
├── password.py # PasswordHasher (~45 LOC)
|
||||
├── notifier.py # EmailSender (renamed - the role, not the verb)
|
||||
├── service.py # UserService (orchestrator) (~135 LOC)
|
||||
└── _queries.py # _build_query (private) (~30 LOC)
|
||||
```
|
||||
|
||||
Every file is < 250 pure LOC. Each owns one concept. The barrel exposes the only public name. The reviewer never has to scroll through password hashing to understand SMTP retry policy.
|
||||
|
||||
#### Rust - BEFORE (`auth.rs`, 380 pure LOC)
|
||||
|
||||
```rust
|
||||
// auth.rs - DOES TOO MUCH
|
||||
pub struct Session { ... } // 40 LOC
|
||||
impl Session { ... } // 90 LOC of methods
|
||||
pub struct TokenIssuer { ... } // 30 LOC
|
||||
impl TokenIssuer { ... } // 70 LOC
|
||||
pub struct RateLimiter { ... } // 50 LOC
|
||||
impl RateLimiter { ... } // 70 LOC
|
||||
fn parse_authorization_header(...) { ... } // 30 LOC
|
||||
```
|
||||
|
||||
#### Rust - AFTER
|
||||
|
||||
```
|
||||
src/auth/
|
||||
├── mod.rs # re-exports Session, TokenIssuer, RateLimiter (8 LOC)
|
||||
├── session.rs # Session + impl (~130 LOC)
|
||||
├── token.rs # TokenIssuer + impl (~100 LOC)
|
||||
├── rate_limit.rs # RateLimiter + impl (~120 LOC)
|
||||
└── header.rs # parse_authorization_header (~35 LOC)
|
||||
```
|
||||
|
||||
#### TypeScript - BEFORE (`api/orders.ts`, 510 pure LOC)
|
||||
|
||||
```typescript
|
||||
// api/orders.ts - DOES TOO MUCH
|
||||
export const OrderSchema = z.object({ ... }) // 30 LOC
|
||||
type Order = z.infer<typeof OrderSchema>
|
||||
export class OrderRepository { ... } // 110 LOC
|
||||
export class PricingEngine { ... } // 130 LOC
|
||||
export class TaxCalculator { ... } // 90 LOC
|
||||
export class OrderService { ... } // 150 LOC
|
||||
```
|
||||
|
||||
#### TypeScript - AFTER
|
||||
|
||||
```
|
||||
src/orders/
|
||||
├── index.ts # barrel (6 LOC)
|
||||
├── schema.ts # OrderSchema + Order type (~35 LOC)
|
||||
├── repository.ts # OrderRepository (~115 LOC)
|
||||
├── pricing.ts # PricingEngine (~135 LOC)
|
||||
├── tax.ts # TaxCalculator (~95 LOC)
|
||||
└── service.ts # OrderService (orchestrator) (~155 LOC)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY POST-WRITE REVIEW LOOP
|
||||
|
||||
**This runs EVERY time you finish writing or substantively editing code, before you claim the task is done.** No exceptions.
|
||||
|
||||
### Step 1 — measure
|
||||
|
||||
For every file you created or modified:
|
||||
|
||||
```bash
|
||||
awk '!/^[[:space:]]*$/ && !/^[[:space:]]*(\/\/|#|--)/' <file> | wc -l
|
||||
```
|
||||
|
||||
Or run the per-language checker the skill ships:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
uv run scripts/python/check-no-excuse-rules.py <changed paths>
|
||||
# Rust
|
||||
bash scripts/rust/check-no-excuse-rules.sh <changed paths>
|
||||
# TypeScript
|
||||
bun run scripts/typescript/check-no-excuse-rules.ts <changed paths>
|
||||
```
|
||||
|
||||
### Step 2 — interpret
|
||||
|
||||
| Pure LOC | Verdict | Required action |
|
||||
|---|---|---|
|
||||
| ≤ 200 | Healthy | continue |
|
||||
| 200 - 250 | **Warning band** - the file is approaching the ceiling. State that fact explicitly in the next message and propose a split if the next planned edit will add lines. |
|
||||
| > 250 | **DEFECT** - the architecture is wrong. Do NOT commit. Refactor into smaller cohesive units **now**, in this same task. |
|
||||
|
||||
### Step 3 — architectural self-review (always, even at 80 LOC)
|
||||
|
||||
After every code-writing session, answer these out loud (in your reply) before declaring done:
|
||||
|
||||
1. **Single responsibility?** Can I name what this file owns in one short noun phrase? If the answer needs the word "and", split.
|
||||
2. **Boundary purity?** Did I parse untrusted input into a typed value at the boundary, or did I pass `dict[str, Any]` / `serde_json::Value` / `unknown` past the boundary? If the latter, fix it.
|
||||
3. **Variant discrimination?** Did I use `if`/`elif`/`else` (or `switch` without `assertNever`, or `match` without `assert_never`) anywhere to discriminate on a tagged type or enum? If yes, rewrite as exhaustive match.
|
||||
4. **Escape hatches?** Any `Any`, `# type: ignore`, `unwrap`, `expect` outside `main`/tests, `as` numeric cast, `!`, `@ts-ignore`, `@ts-expect-error`, `#[allow]` on a real warning? If yes, fix the type or document why with a comment.
|
||||
5. **Defensive layer?** Any null check, try/except, or `isinstance` guarding a value the type system already proves? If yes, delete.
|
||||
6. **Helpers for one-off?** Any function, class, or trait introduced for a single caller that will never get a second caller? If yes, inline.
|
||||
7. **Tests?** Is the behavior I just introduced locked by a test that would fail if I revert this commit?
|
||||
|
||||
**If any answer fails, fix it before declaring done.** This loop is the difference between "the code compiles" and "the code is correct."
|
||||
|
||||
### Step 4 — if you need to refactor right now, invoke the right skill
|
||||
|
||||
- The file you just wrote (or an adjacent one) is over 250 pure LOC, or step 3 surfaced more than two issues: **load the `refactor` skill** and execute its safe-refactor protocol (codemap, plan, LSP-driven edits, test after each step). Do not improvise a refactor under time pressure - the refactor skill exists precisely so you do not corrupt behavior while reshaping structure.
|
||||
- You inherited a branch with AI-generated patterns (broad `except`, redundant null checks, vague TODOs, oversized modules, dead helpers): **load the `remove-ai-slops` skill** to do a categorized branch-scope cleanup with regression tests pinned first.
|
||||
|
||||
These two skills are not optional cosmetics. They are the recovery path for the defects this loop is designed to catch.
|
||||
|
||||
---
|
||||
|
||||
## Companion skills - explicit invocation triggers
|
||||
|
||||
| Trigger | Skill to load | Why |
|
||||
|---|---|---|
|
||||
| File exceeds 250 pure LOC, OR the post-write loop surfaces 2+ issues, OR the user says "reshape this", "extract this", "clean this up" | `refactor` | Safe codemap-driven multi-step refactor with LSP + tests after each step. Never improvise a structural change. |
|
||||
| Recent branch contains AI-authored code that smells (broad except, dead helpers, vague comments, oversized files), OR the user says "remove slop", "clean AI code", "deslop" | `remove-ai-slops` | Tests pinned FIRST, then categorized parallel cleanup, then quality gates. Behavior-preserving. |
|
||||
| Rust code touches `unsafe`, `*mut`, `*const`, `MaybeUninit`, FFI, `unsafe impl Send/Sync`, or a custom lock-free primitive | `references/rust-ub/` | Full UB taxonomy + Miri strictness escalation. Every `unsafe` block must survive Miri Level 3 (strict provenance + symbolic alignment + preemption) before it ships. |
|
||||
|
||||
---
|
||||
|
||||
## Per-language jump table
|
||||
|
||||
**Stop. Read the matching reference fully before writing code.**
|
||||
|
||||
### Python (`.py`, `.pyi`)
|
||||
|
||||
**READ `references/python/README.md` FIRST.** Then load on demand:
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| Strict pyproject.toml / basedpyright / ruff config | `references/python/pyproject-strict.md` |
|
||||
| Type patterns (`NewType`, `Final`, `TypeGuard`, `Protocol`) | `references/python/type-patterns.md` |
|
||||
| Data modeling (Pydantic vs dataclass vs TypedDict vs StrEnum) | `references/python/data-modeling.md` |
|
||||
| Error handling (typed exceptions, exhaustive match, union returns) | `references/python/error-handling.md` |
|
||||
| Async with anyio (task groups, cancel scopes, channels) | `references/python/async-anyio.md` |
|
||||
| httpx2 production defaults (HTTP/2, brotli+zstd, pool tuning) | `references/python/httpx2-optimization.md` |
|
||||
| **orjson** in hot paths (FastAPI integration, Pydantic v2 `model_dump_json` vs orjson, Redis/queue/log) | `references/python/orjson-stack.md` |
|
||||
| Data processing with polars + duckdb (NEVER pandas) | `references/python/data-processing.md` |
|
||||
| FastAPI + SQLAlchemy 2.x async stack | `references/python/fastapi-stack.md` |
|
||||
| pydantic-ai agents | `references/python/pydantic-ai.md` |
|
||||
| Textual TUI | `references/python/textual-tui.md` |
|
||||
| Disposable PEP 723 scripts | `references/python/one-liners.md` |
|
||||
| Canonical library defaults | `references/python/libraries.md` |
|
||||
|
||||
### Rust (`.rs`, `Cargo.toml`)
|
||||
|
||||
**READ `references/rust/README.md` FIRST.** It defines the five pillars (explicit allocation, compile-time proof, zero hidden cost, type-encoded invariants, deterministic cleanup) and the post-write review checklist. Then load on demand:
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| **Arena allocation, const fn, zero-alloc APIs, bitfield, scopeguard, errdefer, Zig-like patterns** | **`references/rust/zero-cost-safety.md`** |
|
||||
| Strict `Cargo.toml` lints + profile + workspace config | `references/rust/cargo-strict.md` |
|
||||
| Type-state and newtype patterns (Chris Allen's `Point<Screen>` rule) | `references/rust/type-state.md` |
|
||||
| `unsafe` discipline (safe wrapper + SAFETY comment + miri proof) | `references/rust/unsafe-discipline.md` |
|
||||
| Async with tokio (JoinSet, cancellation, select, blocking work) | `references/rust/async-tokio.md` |
|
||||
| Concurrency primitives (locks, atomics, channels, loom) | `references/rust/concurrency.md` |
|
||||
| axum + sqlx + tracing + tower HTTP stack | `references/rust/axum-stack.md` |
|
||||
| clap + color-eyre + tracing + indicatif CLI stack | `references/rust/clap-stack.md` |
|
||||
| Property tests (proptest) + snapshot tests (insta) | `references/rust/proptest-insta.md` |
|
||||
| Disposable `rust-script` scripts | `references/rust/one-liners.md` |
|
||||
| Canonical library defaults | `references/rust/libraries.md` |
|
||||
| **ANY `unsafe` / FFI / `MaybeUninit` / lock-free work** | **`references/rust-ub/` (full directory)** |
|
||||
|
||||
### TypeScript (`.ts`, `.tsx`, `.mts`, `.cts`)
|
||||
|
||||
**READ `references/typescript/README.md` FIRST.** Then load on demand:
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| Strict tsconfig + Biome config | `references/typescript/tsconfig-strict.md` |
|
||||
| Type patterns (branded types, `as const`, `satisfies`, narrowing, `assertNever`) | `references/typescript/type-patterns.md` |
|
||||
| Data modeling (type vs interface vs Zod, readonly, parse-don't-validate) | `references/typescript/data-modeling.md` |
|
||||
| Error handling (Result, typed errors, union vs throw, AbortSignal timeouts) | `references/typescript/error-handling.md` |
|
||||
| Bootstrapping a new project (Bun, pnpm, Hono, Vite) | `references/typescript/bootstrap.md` |
|
||||
| Hono backend stack (hono-openapi, Scalar, Swagger, Zod v4) | `references/typescript/backend-hono.md` |
|
||||
|
||||
### Go (`.go`, `go.mod`, `go.sum`, `.golangci.yml`, `*.proto`)
|
||||
|
||||
**READ `references/go/README.md` FIRST.** Then load on demand:
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| Library defaults (gin vs chi, sqlc, slog, the 2026 stack reasoning) | `references/go/libraries.md` |
|
||||
| Canonical strict `.golangci.yml` (v2) with per-linter rationale | `references/go/golangci-strict.md` |
|
||||
| Project layout, Taskfile, CI, `go.mod` template | `references/go/bootstrap.md` |
|
||||
| Type patterns (named types, smart constructors, sealed interfaces, generics) | `references/go/type-patterns.md` |
|
||||
| Data modeling — the three layers of validation (validator/v10 → smart ctor → sqlc) | `references/go/data-modeling.md` |
|
||||
| Error handling (`errors.Is/As`, typed errors, `%w` wrapping, no panic) | `references/go/error-handling.md` |
|
||||
| Concurrency (`context.Context`, `errgroup`, channels, locks, `-race`, `goleak`) | `references/go/concurrency.md` |
|
||||
| HTTP backend stack (gin + slog + validator + pgx, middleware ordering, SSE, WS) | `references/go/backend-stack.md` |
|
||||
| RPC stack (Connect-Go default, grpc-go fallback, protovalidate, Buf) | `references/go/grpc-connect.md` |
|
||||
| CLI stack (cobra + slog + huh) | `references/go/cobra-stack.md` |
|
||||
| Database stack (sqlc + pgx + goose + testcontainers) | `references/go/sqlc-pgx.md` |
|
||||
| TUI stack (bubbletea v2 + bubbles v2 + lipgloss v2; **CJK / IME support**) | `references/go/bubbletea-v2.md` |
|
||||
| Testing (Given/When/Then, table-driven, fakes-over-mocks, autogold, rapid) | `references/go/testing.md` |
|
||||
| Disposable `go run` scripts | `references/go/one-liners.md` |
|
||||
|
||||
---
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates whenever you are writing or modifying any `.py`, `.pyi`, `.rs`, `.ts`, `.tsx`, `.mts`, `.cts`, `.go` file, or any project manifest (`pyproject.toml`, `Cargo.toml`, `package.json`, `tsconfig.json`, `biome.json`, `go.mod`, `go.sum`, `.golangci.yml`, `Taskfile.yml`, `buf.yaml`, `sqlc.yaml`). **Even one-off scripts get the full treatment** - that is the whole point of `uv run` + PEP 723, `rust-script`, `bun run`, and `go run` + `//go:build ignore`: production hygiene with throwaway ergonomics.
|
||||
|
||||
The references contain the recipes. **Read them before writing code. Re-read them when the model drifts.** The post-write review loop is non-negotiable.
|
||||
@@ -0,0 +1,90 @@
|
||||
|
||||
# Go Programmer
|
||||
|
||||
Production Go in 2026. **Boring on purpose, strict by tooling, illegal states unrepresentable by convention.**
|
||||
|
||||
## Philosophy
|
||||
|
||||
Go gives you fewer type-system tools than Python, TypeScript, or Rust:
|
||||
|
||||
- No sum types — only `interface{}` with type-switch.
|
||||
- No exhaustiveness check from the compiler — only the `exhaustive` linter.
|
||||
- No `Option<T>` — only `nil` and the eternal trap of "is this nil interface or nil concrete?".
|
||||
- No `Result<T, E>` — only `(T, error)`, no compiler enforcement of unwrapping.
|
||||
- No newtype that prevents primitive coercion — `type UserID string` is still implicitly convertible from a literal when used carelessly.
|
||||
|
||||
**This is the whole point of the skill.** Where the language is weak, the linter bundle becomes the type checker, and code patterns become the type system. Treat `golangci-lint v2` with the configuration in `golangci-strict.md` as if it were `tsc --strict` or `basedpyright`. Treat `nilaway` and `go test -race` as if they were Miri.
|
||||
|
||||
The skill enforces five non-negotiables:
|
||||
|
||||
1. **Parse-don't-validate at every boundary.** HTTP/RPC/CLI/config gets parsed into a domain struct constructed only via `New*(...)` smart constructors. Once inside the domain, no further validation. See `data-modeling.md`.
|
||||
2. **`(T, error)` everywhere.** No panics in library code. No bare `_ = err`. Errors are wrapped with `%w` and asserted with `errors.Is` / `errors.As`. Typed error structs for anything a caller can branch on. See `error-handling.md`.
|
||||
3. **Sealed interfaces for variants.** Sum types via a sealed unexported method, dispatched through a `type switch`, with the `exhaustive` linter checking completeness. See `type-patterns.md`.
|
||||
4. **`context.Context` is the first parameter.** Always. No `context.Background()` inside leaf functions. No goroutine without context-driven shutdown. No `time.Now()` in domain code — inject a clock. See `concurrency.md`.
|
||||
5. **Generated, not hand-written, for external contracts.** `sqlc` for DB, `oapi-codegen` for OpenAPI servers and clients, `protoc-gen-go` + `protoc-gen-connect-go` for RPC. Hand-rolled marshalling is a regression. See `sqlc-pgx.md`, `grpc-connect.md`.
|
||||
|
||||
## Hard rules — tooling
|
||||
|
||||
| Category | Use | Never |
|
||||
|---|---|---|
|
||||
| Go version | **1.23+** (range-over-func, iter package, slog stable) | <1.22 |
|
||||
| Module | `go modules` + `go work` for monorepos | dep, GOPATH layouts |
|
||||
| Format | **`gofumpt`** (stricter gofmt) + `goimports -local <module>` | bare `gofmt` |
|
||||
| Linter | **`golangci-lint v2`** with the strict bundle in `golangci-strict.md` | bare `go vet` |
|
||||
| Nil checker | **`nilaway`** (Uber, stable since 2024) in CI | hope |
|
||||
| Vet bundle | `go vet` + `fieldalignment` + `shadow` | "tests cover it" |
|
||||
| Tests | `go test -race -shuffle=on -count=1` | `-count` cache, no race |
|
||||
| Goroutine leaks | `go.uber.org/goleak` in `TestMain` | "looks fine" |
|
||||
| Mock | `go.uber.org/mock` (gomock successor) | hand-written stubs |
|
||||
| DB | `sqlc` + `jackc/pgx/v5` | `database/sql` + `gorm` |
|
||||
| HTTP framework | **`gin-gonic/gin`** (de facto, ~48% of Go API repos) — `go-chi/chi` for minimalist, `connectrpc/connect-go` for RPC | `echo` (smaller eco), `fiber` (fasthttp = non-stdlib), `gorilla/mux` (in maintenance mode) |
|
||||
| RPC | **`connectrpc/connect-go`** (gRPC-compatible, HTTP/1.1-friendly, browser-friendly) | hand-rolled `grpc-go` unless you specifically need bidi streaming features Connect lacks |
|
||||
| Validation | `go-playground/validator/v10` for HTTP boundary + `bufbuild/protovalidate-go` for proto + smart constructors for domain | ad-hoc `if len(s) == 0` chains |
|
||||
| Config | `caarlos0/env/v11` (struct-tag env) | `viper` unless you actually need file+env+flag merging |
|
||||
| Logging | **`log/slog`** (stdlib, Go 1.21+) | logrus, zap, zerolog (all superseded) |
|
||||
| CLI | `spf13/cobra` | hand-rolled `os.Args` parsing past 2 flags |
|
||||
| TUI | `charm.land/bubbletea/v2` + `bubbles/v2` + `lipgloss/v2` — see `bubbletea-v2.md` for CJK/IME | bubbletea v1 if you need IME |
|
||||
|
||||
A single CI command should be the gate:
|
||||
|
||||
```bash
|
||||
gofumpt -l . && \
|
||||
golangci-lint run ./... && \
|
||||
nilaway ./... && \
|
||||
go test -race -shuffle=on -count=1 ./...
|
||||
```
|
||||
|
||||
If any of these fails, the change is not done. Period. The bundle is set up so a clean run actually means clean — see `golangci-strict.md` for the per-linter rationale and the deliberate `nolint:` policy.
|
||||
|
||||
## Hard rules — code
|
||||
|
||||
Read these per-file references for the canonical patterns:
|
||||
|
||||
- **Types & data** → `type-patterns.md`, `data-modeling.md` — branded named types, smart constructors with unexported fields, sealed interfaces as sum types.
|
||||
- **Errors** → `error-handling.md` — sentinel vs typed struct, `errors.Is/As`, `%w` wrapping, no panic in libraries, the `errorlint` ruleset.
|
||||
- **Concurrency** → `concurrency.md` — `context.Context` discipline, `errgroup`, `sync.OnceValue`, `goleak`, `-race`, channel selection rules.
|
||||
- **HTTP backend** → `backend-stack.md` — `gin` server skeleton, middleware ordering, SSE/streaming with `http.Flusher`, structured slog logging, graceful shutdown — distilled from the CLIProxyAPI codebase (a real proxy serving OpenAI/Gemini/Claude APIs).
|
||||
- **RPC** → `grpc-connect.md` — when to pick Connect vs grpc-go, codegen pipeline, protovalidate, streaming.
|
||||
- **DB** → `sqlc-pgx.md` — compile-time-safe SQL via sqlc + pgx connection pool + migrations via goose + testcontainers in CI.
|
||||
- **CLI** → `cobra-stack.md` — cobra layout, slog integration, graceful shutdown on signals, fang-style colored help.
|
||||
- **TUI** → `bubbletea-v2.md` — v2 model, `SetVirtualCursor(false)` + `tea.View{Cursor}` for CJK IME, why v1 was broken for Korean/Japanese/Chinese input.
|
||||
- **Testing** → `testing.md` — table-driven tests, `require` vs `assert`, `autogold` snapshots, `gopter` property tests, `testcontainers` for integration, `goleak` for goroutine leaks.
|
||||
- **Bootstrap** → `bootstrap.md` — `new-project.go` invocation, project layout (`cmd/`, `internal/`, `pkg/`), Taskfile, CI.
|
||||
- **Strict config** → `golangci-strict.md` — the canonical `.golangci.yml` with the full linter whitelist and per-linter rationale.
|
||||
- **One-liners** → `one-liners.md` — `go run` scripts with `//go:build ignore`, `gorun`-style invocation.
|
||||
|
||||
## The 250 pure LOC ceiling
|
||||
|
||||
Same rule as Python/Rust/TS: a `.go` file whose pure LOC (non-blank, non-comment) exceeds 250 is architecturally broken. Go encourages many small files in a single package, so this is *more* natural here than elsewhere — split by responsibility, keep one cohesive type and its methods per file.
|
||||
|
||||
The `cmd/server/main.go` is the most common violator. Refactor it: `main.go` only wires `os.Args` → `cmd.Execute()`. Anything else lives in `internal/`.
|
||||
|
||||
## Existing codebases — non-strict project
|
||||
|
||||
When editing an existing `.go` file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.** Use the `remove-ai-slops` skill for branch-scope cleanup.
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates whenever you write or modify any `.go` file, `go.mod`, `go.sum`, `.golangci.yml`, `Taskfile.yml`, or any of the codegen specs (`*.proto`, `*.sql` next to `sqlc.yaml`, `openapi.yaml` next to `oapi-codegen.yaml`). Even one-off scripts get the strict treatment — that is what `//go:build ignore` + `go run` is for: production hygiene with throwaway ergonomics.
|
||||
|
||||
The references contain the recipes. **Read them before writing code. Re-read them when the model drifts.** The post-write architectural review loop is non-negotiable.
|
||||
@@ -0,0 +1,641 @@
|
||||
# HTTP Backend Stack — gin + slog + validator + pgx
|
||||
|
||||
The canonical production HTTP service skeleton. Distilled from the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) codebase — a real proxy serving OpenAI / Gemini / Claude / Codex APIs in production, with SSE streaming, WebSocket upgrades, request logging, and hot-reload config.
|
||||
|
||||
If you are tempted to pick echo or chi instead, see `libraries.md` — gin wins on ecosystem, not technical merit, and the win is large enough to matter.
|
||||
|
||||
---
|
||||
|
||||
## `go.mod`
|
||||
|
||||
```go
|
||||
module github.com/your-org/myservice
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/caarlos0/env/v11 v11.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/sync v0.18.0
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
cmd/server/main.go # ≤ 50 LOC; flags → run.Execute(ctx)
|
||||
internal/
|
||||
cmd/run.go # ~150 LOC; signal handling, config load, server.Run
|
||||
config/config.go # env-driven Config struct
|
||||
api/
|
||||
server.go # gin.Engine setup, route mounting, http.Server
|
||||
middleware/
|
||||
request_id.go
|
||||
request_logging.go
|
||||
auth.go
|
||||
recovery.go
|
||||
cors.go
|
||||
handlers/
|
||||
users.go # one file per resource
|
||||
streams.go # SSE / WebSocket endpoints
|
||||
domain/ # smart-constructor types (Email, UserID, ...)
|
||||
service/ # business logic
|
||||
store/ # pgx + sqlc
|
||||
obs/
|
||||
logger.go # slog setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `cmd/server/main.go`
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/your-org/myservice/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := cmd.Execute(ctx); err != nil {
|
||||
slog.Error("fatal", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That is the entire `main`. Anything more is a smell.
|
||||
|
||||
---
|
||||
|
||||
## `internal/config/config.go`
|
||||
|
||||
```go
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string `env:"HOST" envDefault:"0.0.0.0"`
|
||||
Port int `env:"PORT" envDefault:"8080"`
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"15s"`
|
||||
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"`
|
||||
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" envDefault:"json"`
|
||||
Env string `env:"ENV" envDefault:"development"`
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `internal/obs/logger.go`
|
||||
|
||||
```go
|
||||
package obs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ctxKey struct{ name string }
|
||||
var requestIDKey = ctxKey{"request_id"}
|
||||
|
||||
func NewLogger(level, format string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
_ = lvl.UnmarshalText([]byte(level))
|
||||
|
||||
opts := &slog.HandlerOptions{Level: lvl, AddSource: true}
|
||||
|
||||
var h slog.Handler
|
||||
switch format {
|
||||
case "text":
|
||||
h = slog.NewTextHandler(os.Stdout, opts)
|
||||
default:
|
||||
h = slog.NewJSONHandler(os.Stdout, opts)
|
||||
}
|
||||
return slog.New(&ctxHandler{Handler: h})
|
||||
}
|
||||
|
||||
// ctxHandler pulls request_id from ctx into every log line.
|
||||
type ctxHandler struct{ slog.Handler }
|
||||
|
||||
func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" {
|
||||
r.AddAttrs(slog.String("request_id", id))
|
||||
}
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey, id)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `internal/api/server.go`
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/your-org/myservice/internal/api/handlers"
|
||||
"github.com/your-org/myservice/internal/api/middleware"
|
||||
"github.com/your-org/myservice/internal/config"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
srv *http.Server
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger, h *handlers.Handler) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
|
||||
// Middleware order matters — see "Middleware ordering" below.
|
||||
r.Use(
|
||||
middleware.RequestID(), // 1. assign request_id first
|
||||
middleware.Recovery(logger), // 2. recovery wraps everything
|
||||
middleware.RequestLogger(logger),
|
||||
middleware.CORS(),
|
||||
)
|
||||
|
||||
h.Mount(r)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
srv: &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
s.logger.InfoContext(ctx, "server starting",
|
||||
slog.String("addr", s.srv.Addr))
|
||||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.InfoContext(ctx, "shutdown signal received")
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
context.Background(), s.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return s.srv.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `gin.New()` not `gin.Default()` — `Default()` adds `Logger()` (text format, not slog) and `Recovery()` (no logger injection). We replace both.
|
||||
- `gin.SetMode(gin.ReleaseMode)` silences debug output. Production assumed.
|
||||
- `http.Server` with explicit timeouts. The default `nil` timeouts are a DoS waiting to happen.
|
||||
- Graceful shutdown: SIGINT/SIGTERM cancels the ctx → `Shutdown(shutdownCtx)` gives in-flight requests up to `ShutdownTimeout` to finish.
|
||||
|
||||
---
|
||||
|
||||
## Middleware ordering — the rule that actually matters
|
||||
|
||||
```
|
||||
RequestID → Recovery → Logger → CORS → Auth → Handler
|
||||
(1) (2) (3) (4) (5)
|
||||
```
|
||||
|
||||
1. **RequestID** is first so every subsequent middleware sees it.
|
||||
2. **Recovery** wraps everything after it. Order: a panic in CORS still gets caught.
|
||||
3. **Logger** sees the request_id and the recovered panic.
|
||||
4. **CORS** before Auth — OPTIONS preflight must return without auth.
|
||||
5. **Auth** is the last cross-cutting middleware. Per-route auth (admin-only) is mounted on a sub-router with extra middleware.
|
||||
|
||||
```go
|
||||
// Public routes — no auth
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
api.POST("/auth/login", h.Login)
|
||||
api.GET("/healthz", h.Healthz)
|
||||
}
|
||||
|
||||
// Authenticated routes
|
||||
authed := r.Group("/api/v1", middleware.Auth(authSvc))
|
||||
{
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users", h.CreateUser)
|
||||
}
|
||||
|
||||
// Admin-only routes
|
||||
admin := r.Group("/api/v1/admin",
|
||||
middleware.Auth(authSvc),
|
||||
middleware.RequireRole("admin"))
|
||||
{
|
||||
admin.GET("/users", h.ListAllUsers)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Middleware examples
|
||||
|
||||
### `middleware/request_id.go`
|
||||
|
||||
```go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/your-org/myservice/internal/obs"
|
||||
)
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader("X-Request-ID")
|
||||
if id == "" {
|
||||
id = uuid.Must(uuid.NewV7()).String()
|
||||
}
|
||||
c.Request = c.Request.WithContext(obs.WithRequestID(c.Request.Context(), id))
|
||||
c.Header("X-Request-ID", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `middleware/recovery.go`
|
||||
|
||||
```go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Recovery(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "panic recovered",
|
||||
slog.Any("panic", r),
|
||||
slog.String("stack", string(debug.Stack())),
|
||||
)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError,
|
||||
gin.H{"error": "internal_error"})
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `middleware/request_logging.go`
|
||||
|
||||
```go
|
||||
func RequestLogger(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
logger.InfoContext(c.Request.Context(), "http request",
|
||||
slog.String("method", c.Request.Method),
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.Int("status", c.Writer.Status()),
|
||||
slog.Int("bytes", c.Writer.Size()),
|
||||
slog.Duration("elapsed", time.Since(start)),
|
||||
slog.String("ip", c.ClientIP()),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `sloglint` linter enforces typed attrs (`slog.String(...)`) over `slog.Any("path", ...)`. Keep the form.
|
||||
|
||||
### `middleware/cors.go`
|
||||
|
||||
```go
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "*")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note the explicit OPTIONS short-circuit — preflight must NOT go through Auth.
|
||||
|
||||
---
|
||||
|
||||
## Handlers — the canonical shape
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/your-org/myservice/internal/domain"
|
||||
"github.com/your-org/myservice/internal/httperr"
|
||||
"github.com/your-org/myservice/internal/service"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Users *service.UserService
|
||||
}
|
||||
|
||||
func (h *Handler) Mount(r gin.IRouter) {
|
||||
api := r.Group("/api/v1")
|
||||
api.POST("/users", h.CreateUser)
|
||||
api.GET("/users/:id", h.GetUser)
|
||||
}
|
||||
|
||||
type createUserReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBindingError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
email, err := domain.NewEmail(req.Email)
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
username, err := domain.NewUsername(req.Username)
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Users.Create(c.Request.Context(), email, username)
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func writeBindingError(c *gin.Context, err error) {
|
||||
var vErr validator.ValidationErrors
|
||||
if errors.As(err, &vErr) {
|
||||
out := make(map[string]string, len(vErr))
|
||||
for _, fe := range vErr {
|
||||
out[fe.Field()] = fe.Tag()
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"errors": out})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
|
||||
}
|
||||
```
|
||||
|
||||
See `data-modeling.md` for the validator tag reference; see `error-handling.md` for the `httperr.Write` funnel.
|
||||
|
||||
---
|
||||
|
||||
## SSE streaming — the production pattern
|
||||
|
||||
CLIProxyAPI streams OpenAI-compatible SSE for hundreds of concurrent clients. The pattern:
|
||||
|
||||
```go
|
||||
func (h *Handler) StreamChat(c *gin.Context) {
|
||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||
defer cancel()
|
||||
|
||||
// 1. Set SSE headers BEFORE writing any body
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no") // disable nginx buffering
|
||||
|
||||
// 2. Obtain the flusher — REQUIRED for streaming
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
httperr.Write(c, errors.New("streaming unsupported"))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Pull chunks from upstream
|
||||
chunks, errs := h.svc.StreamCompletions(ctx, req)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // client disconnected, ctx cancelled
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
fmt.Fprint(c.Writer, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
|
||||
flusher.Flush()
|
||||
case err := <-errs:
|
||||
// Error mid-stream — emit as SSE event and bail
|
||||
fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", err.Error())
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key facts:
|
||||
|
||||
- **Headers MUST be set before the first `Write`.** Otherwise gin auto-sets `Content-Type: text/plain`.
|
||||
- **`c.Writer.(http.Flusher)` is the streaming primitive.** Without `flusher.Flush()`, the response is buffered and arrives as one blob at the end.
|
||||
- **Always respond to `<-ctx.Done()`.** A disconnected client must stop upstream work — otherwise you generate tokens for nothing.
|
||||
- **The trailing `\n\n` per event is wire-mandatory** for SSE parsing. Missing it = the client never sees the event.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket upgrade
|
||||
|
||||
```go
|
||||
import "github.com/gorilla/websocket" // still the canonical WS lib in 2026
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// tighten in production
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
func (h *Handler) WebSocketEcho(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
slog.ErrorContext(c.Request.Context(), "ws upgrade failed", slog.Any("err", err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
mt, msg, err := conn.ReadMessage()
|
||||
if err != nil { return }
|
||||
if err := conn.WriteMessage(mt, msg); err != nil { return }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For long-lived connections, use `conn.SetReadDeadline` + `SetPongHandler` for keepalive. CLIProxyAPI's `wsrelay` package is a reference implementation.
|
||||
|
||||
---
|
||||
|
||||
## Database wiring — pgx pool, injected, never global
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 25
|
||||
cfg.MinConns = 5
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
cfg.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
```
|
||||
|
||||
See `sqlc-pgx.md` for queries.
|
||||
|
||||
---
|
||||
|
||||
## Healthcheck
|
||||
|
||||
```go
|
||||
func (h *Handler) Healthz(c *gin.Context) {
|
||||
if err := h.pool.Ping(c.Request.Context()); err != nil {
|
||||
c.JSON(503, gin.H{"db": "down", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
```
|
||||
|
||||
Mount BEFORE auth. Health checks must be unauthenticated.
|
||||
|
||||
---
|
||||
|
||||
## Testing the server
|
||||
|
||||
```go
|
||||
func TestCreateUser_returns_201_for_valid_input(t *testing.T) {
|
||||
// Given
|
||||
h := newTestHandler(t)
|
||||
r := gin.New()
|
||||
h.Mount(r)
|
||||
|
||||
body := `{"email":"a@b.com","username":"alice"}`
|
||||
req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// When
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// Then
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var got struct{ ID string `json:"id"` }
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.NotEmpty(t, got.ID)
|
||||
}
|
||||
```
|
||||
|
||||
See `testing.md` for full patterns (testcontainers integration, table-driven, goleak).
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- gin docs: https://gin-gonic.com/docs/
|
||||
- CLIProxyAPI (reference impl): https://github.com/router-for-me/CLIProxyAPI
|
||||
- pgx pool: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
|
||||
- SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
||||
- Go's `http.Server` graceful shutdown: https://pkg.go.dev/net/http#Server.Shutdown
|
||||
@@ -0,0 +1,328 @@
|
||||
# Bootstrap — Project Layout, Toolchain, Taskfile, CI
|
||||
|
||||
What every new Go project gets in the first 60 seconds. Drop the script in `scripts/go/new-project.go` does all of this — this document explains *what* it produces and *why*.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
`go.work` (monorepo) or just rely on `go.mod`'s `go 1.23` directive (single module). Go 1.21+ auto-downloads matching toolchain when the local `go` binary is older. **No `.tool-versions` / `asdf` / `mise` indirection required** unless your shop standardizes on it.
|
||||
|
||||
```bash
|
||||
# Confirm a working toolchain
|
||||
go env GOTOOLCHAIN # should be "auto" or your pinned version
|
||||
go version # ≥ 1.23
|
||||
```
|
||||
|
||||
## Required global installs
|
||||
|
||||
These are CLI tools, installed once per machine via `go install`:
|
||||
|
||||
```bash
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
|
||||
go install go.uber.org/nilaway/cmd/nilaway@latest
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
go install go.uber.org/mock/mockgen@latest
|
||||
go install github.com/go-task/task/v3/cmd/task@latest
|
||||
```
|
||||
|
||||
For Connect/protobuf projects, additionally:
|
||||
|
||||
```bash
|
||||
go install github.com/bufbuild/buf/cmd/buf@latest
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
|
||||
```
|
||||
|
||||
## Project layout — the canonical tree
|
||||
|
||||
```
|
||||
myservice/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Taskfile.yml # task runner
|
||||
├── .golangci.yml # see golangci-strict.md
|
||||
├── .editorconfig
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── AGENTS.md # agent-readable project facts
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # ONLY: parse flags, call cmd.Execute(); ≤ 50 LOC
|
||||
├── internal/ # NEVER importable from outside this module
|
||||
│ ├── api/ # transport layer (gin/connect routers)
|
||||
│ │ ├── server.go # gin engine setup, route registration
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── request_id.go
|
||||
│ │ │ ├── logging.go
|
||||
│ │ │ └── auth.go
|
||||
│ │ └── handlers/
|
||||
│ │ ├── users.go
|
||||
│ │ └── users_test.go
|
||||
│ ├── domain/ # parse-don't-validate types, smart constructors
|
||||
│ │ ├── user.go
|
||||
│ │ └── email.go
|
||||
│ ├── service/ # business logic, depends on domain only
|
||||
│ │ └── user_service.go
|
||||
│ ├── store/ # persistence; sqlc-generated code lives here
|
||||
│ │ ├── sqlc/ # sqlc-generated, do not hand-edit
|
||||
│ │ ├── queries/ # *.sql files sqlc reads
|
||||
│ │ └── migrations/ # goose migrations
|
||||
│ ├── config/ # env-driven config (caarlos0/env)
|
||||
│ │ └── config.go
|
||||
│ └── obs/ # observability: slog setup, otel, healthz
|
||||
│ └── logger.go
|
||||
├── pkg/ # exportable libraries — only if you publish
|
||||
│ └── …
|
||||
├── proto/ # *.proto definitions (Connect/gRPC projects)
|
||||
│ └── service.proto
|
||||
├── gen/ # generated code (Connect, OpenAPI)
|
||||
│ └── service/v1/
|
||||
│ ├── service.pb.go
|
||||
│ └── servicev1connect/
|
||||
├── test/ # cross-cutting test helpers, fixtures
|
||||
└── .github/workflows/ci.yml
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
|
||||
- `cmd/<binary>/main.go` is ≤ 50 LOC. Anything more lives in `internal/cmd/`.
|
||||
- `internal/` is **the** business code. Other modules cannot import it (Go compiler-enforced).
|
||||
- `pkg/` is for things you genuinely want third parties to import. Empty until proven otherwise.
|
||||
- No `utils/`, `helpers/`, `common/`, `shared/`. **REJECT.** Files are named after the concept they own.
|
||||
- One package per directory. One responsibility per package.
|
||||
|
||||
## `Taskfile.yml` — the entry point for every action
|
||||
|
||||
`go-task/task` is the modern Make replacement. Cross-platform, YAML, fast.
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
BINARY: server
|
||||
PKG: ./cmd/server
|
||||
|
||||
tasks:
|
||||
default:
|
||||
deps: [fmt, lint, test]
|
||||
|
||||
fmt:
|
||||
desc: Format all Go files
|
||||
cmds:
|
||||
- gofumpt -w .
|
||||
- goimports -w -local "$(go list -m)" .
|
||||
|
||||
lint:
|
||||
desc: Run all linters
|
||||
cmds:
|
||||
- golangci-lint run --timeout 5m ./...
|
||||
- nilaway -include-pkgs "$(go list -m)/..." ./...
|
||||
|
||||
test:
|
||||
desc: Run tests with race detector
|
||||
cmds:
|
||||
- go test -race -shuffle=on -count=1 ./...
|
||||
|
||||
test-cover:
|
||||
desc: Coverage report
|
||||
cmds:
|
||||
- go test -race -shuffle=on -count=1 -coverprofile=coverage.out ./...
|
||||
- go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
build:
|
||||
desc: Build the binary
|
||||
cmds:
|
||||
- go build -trimpath -ldflags="-s -w" -o bin/{{.BINARY}} {{.PKG}}
|
||||
|
||||
run:
|
||||
desc: Run the server locally
|
||||
deps: [build]
|
||||
cmds:
|
||||
- ./bin/{{.BINARY}}
|
||||
|
||||
gen:
|
||||
desc: Run all code generators
|
||||
cmds:
|
||||
- task: gen:sqlc
|
||||
- task: gen:mocks
|
||||
- task: gen:proto
|
||||
|
||||
gen:sqlc:
|
||||
cmds:
|
||||
- sqlc generate
|
||||
sources:
|
||||
- internal/store/queries/*.sql
|
||||
- internal/store/sqlc.yaml
|
||||
generates:
|
||||
- internal/store/sqlc/*.go
|
||||
|
||||
gen:mocks:
|
||||
cmds:
|
||||
- go generate ./...
|
||||
|
||||
gen:proto:
|
||||
cmds:
|
||||
- buf generate
|
||||
sources:
|
||||
- proto/**/*.proto
|
||||
- buf.yaml
|
||||
- buf.gen.yaml
|
||||
|
||||
migrate:up:
|
||||
cmds:
|
||||
- goose -dir internal/store/migrations postgres "$DATABASE_URL" up
|
||||
|
||||
migrate:down:
|
||||
cmds:
|
||||
- goose -dir internal/store/migrations postgres "$DATABASE_URL" down
|
||||
|
||||
ci:
|
||||
desc: Everything CI does, locally
|
||||
deps: [fmt, lint, test, build]
|
||||
```
|
||||
|
||||
`task` (no args) runs format + lint + test in parallel where possible. `task ci` runs the full pipeline.
|
||||
|
||||
## `go.mod` template
|
||||
|
||||
```go
|
||||
module github.com/your-org/myservice
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.2.2
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/sync v0.18.0
|
||||
)
|
||||
```
|
||||
|
||||
Only direct deps listed; `go mod tidy` populates indirects.
|
||||
|
||||
## `.editorconfig`
|
||||
|
||||
```ini
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml,json,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
```
|
||||
|
||||
## `.gitignore`
|
||||
|
||||
```gitignore
|
||||
bin/
|
||||
coverage.out
|
||||
coverage.html
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Secrets
|
||||
*.pem
|
||||
*.key
|
||||
```
|
||||
|
||||
## CI — minimal GitHub Actions
|
||||
|
||||
`.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: ci
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache: true
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
|
||||
go install go.uber.org/nilaway/cmd/nilaway@latest
|
||||
go install github.com/go-task/task/v3/cmd/task@latest
|
||||
|
||||
- name: Format check
|
||||
run: gofumpt -l . | (! grep .)
|
||||
|
||||
- name: Lint
|
||||
run: golangci-lint run --timeout 5m ./...
|
||||
|
||||
- name: Nilaway
|
||||
run: nilaway ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -race -shuffle=on -count=1 ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -trimpath ./...
|
||||
```
|
||||
|
||||
The order matters: format → lint → nilaway → test → build. Fail fast on the cheap checks.
|
||||
|
||||
## `AGENTS.md` — agent-readable project facts
|
||||
|
||||
Every new project gets an `AGENTS.md` at the root. The content is **machine-friendly**: short, declarative, no marketing prose. Example:
|
||||
|
||||
```markdown
|
||||
# AGENTS.md
|
||||
|
||||
Go 1.23+ HTTP service for {one-line purpose}.
|
||||
|
||||
## Commands
|
||||
- `task` — fmt + lint + test
|
||||
- `task build` — produce ./bin/server
|
||||
- `task gen` — regenerate sqlc + mocks + proto
|
||||
|
||||
## Architecture
|
||||
- `cmd/server/main.go` — entrypoint, ≤50 LOC
|
||||
- `internal/api/` — gin handlers + middleware
|
||||
- `internal/domain/` — smart-constructor types, no I/O
|
||||
- `internal/store/sqlc/` — generated; never hand-edit
|
||||
|
||||
## Conventions
|
||||
- `slog` for all logs; never `log.*`, never `fmt.Println`
|
||||
- `context.Context` first arg for every public function
|
||||
- Errors wrapped with `%w`; check with `errors.Is/As`
|
||||
- 250 pure LOC ceiling per file — split before adding lines
|
||||
```
|
||||
|
||||
The skill's `cmd/new-project.go` writes this file with project-specific values filled in.
|
||||
|
||||
## Sources
|
||||
|
||||
- Go modules reference: https://go.dev/ref/mod
|
||||
- go-task: https://taskfile.dev
|
||||
- golangci-lint v2: https://golangci-lint.run/docs/configuration/
|
||||
- Standard project layout debate: https://go.dev/doc/modules/layout (NOT `golang-standards/project-layout` — that repo is community, not official)
|
||||
@@ -0,0 +1,360 @@
|
||||
# Bubbletea v2 — TUI with First-Class CJK / IME Support
|
||||
|
||||
The TUI stack for 2026. Use **v2 RC**, not v1. If your users include Korean, Japanese, or Chinese speakers, v1 is broken — IME composition lands in the wrong cells. v2 fixes this. This document is the canonical setup.
|
||||
|
||||
The reference implementation this document is distilled from: [`code-yeongyu/bubbletea-wm`](https://github.com/code-yeongyu/bubbletea-wm) — a floating window manager built specifically to nail down v2 + IME.
|
||||
|
||||
---
|
||||
|
||||
## Why v2 (not v1) — the IME story
|
||||
|
||||
Bubbletea v1 manages cursor positioning in software ("virtual cursor"). It draws a `█` at the cursor position. The terminal's *real* cursor stays at `(0, 0)`.
|
||||
|
||||
This breaks every CJK input method. IME candidate windows (the popup showing 가/각/간 for Korean Hangul composition, kana → kanji for Japanese, pinyin lookup for Chinese) anchor to the terminal's **real** cursor position. With v1, the candidate window appears at top-left while you are typing somewhere in the middle of the screen.
|
||||
|
||||
Bubbletea v2 fixes this with two changes:
|
||||
|
||||
1. **`tea.View{Cursor: *tea.Cursor}`** — your `View()` method returns a view that *includes* the desired cursor position. The framework moves the terminal's real cursor there.
|
||||
2. **`textarea.SetVirtualCursor(false)`** — textareas no longer draw their own `█`. They expose `.Cursor()` so you can read where they want the real cursor.
|
||||
|
||||
Together: IME popups appear where the user is typing. As they should.
|
||||
|
||||
### Other v2 wins (incidental)
|
||||
|
||||
- `tea.MouseClickMsg` / `MouseMotionMsg` / `MouseReleaseMsg` instead of one coarse `MouseMsg`.
|
||||
- Cleaner `View` struct with `AltScreen`, `MouseMode` fields instead of `tea.Cmd` setters.
|
||||
- Pluggable rendering pipeline; better performance under high message volume.
|
||||
|
||||
---
|
||||
|
||||
## `go.mod`
|
||||
|
||||
```go
|
||||
module github.com/your-org/mytui
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/mattn/go-runewidth v0.0.19
|
||||
)
|
||||
```
|
||||
|
||||
The packages live under `charm.land/` (NOT `github.com/charmbracelet/...`) for v2. This is the Charm team's deliberate import-path break to keep v2 separate from v1 until stable.
|
||||
|
||||
---
|
||||
|
||||
## Minimal app — the IME-correct skeleton
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
width, height int
|
||||
ta textarea.Model
|
||||
}
|
||||
|
||||
func initial() model {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Type Korean / Japanese / Chinese here..."
|
||||
ta.SetWidth(60)
|
||||
ta.SetHeight(10)
|
||||
ta.SetVirtualCursor(false) // ← THE LINE. Without this, IME breaks.
|
||||
ta.Focus()
|
||||
return model{ta: ta}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return textarea.Blink }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.ta, cmd = m.ta.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
var view tea.View
|
||||
view.AltScreen = true
|
||||
view.SetContent(m.ta.View())
|
||||
|
||||
// ── THE OTHER LINE. Position the REAL cursor for IME. ──
|
||||
if cursor := m.ta.Cursor(); cursor != nil {
|
||||
view.Cursor = cursor
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(initial(), tea.WithAltScreen()).Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("bye")
|
||||
}
|
||||
```
|
||||
|
||||
The two lines that matter:
|
||||
|
||||
1. `ta.SetVirtualCursor(false)` — disables the virtual `█`.
|
||||
2. `view.Cursor = cursor` (where `cursor = m.ta.Cursor()`) — exports the real cursor position to the framework.
|
||||
|
||||
Without **both**, IME breaks.
|
||||
|
||||
---
|
||||
|
||||
## CJK width — go-runewidth, not `len()`
|
||||
|
||||
Korean, Japanese, Chinese characters render as **two terminal cells** (wide characters per Unicode East Asian Width). Naive `len(string)` returns byte count, not display width. `utf8.RuneCountInString` returns rune count, also not display width.
|
||||
|
||||
Use `github.com/mattn/go-runewidth`:
|
||||
|
||||
```go
|
||||
import "github.com/mattn/go-runewidth"
|
||||
|
||||
func displayWidth(s string) int {
|
||||
return runewidth.StringWidth(s)
|
||||
}
|
||||
|
||||
// Wide character occupies two cells; pad accordingly
|
||||
for _, r := range s {
|
||||
cell := string(r)
|
||||
w := runewidth.RuneWidth(r)
|
||||
canvas = append(canvas, cell)
|
||||
if w == 2 {
|
||||
canvas = append(canvas, "") // placeholder for second cell
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`lipgloss/v2` uses `go-runewidth` internally — `lipgloss.Width("안녕")` returns 4, not 2. **If you measure outside lipgloss, you must call runewidth directly.**
|
||||
|
||||
---
|
||||
|
||||
## Mouse — v2 has typed events
|
||||
|
||||
```go
|
||||
case tea.MouseClickMsg:
|
||||
// msg.X, msg.Y, msg.Button
|
||||
return m.handleClick(msg.X, msg.Y, msg.Button)
|
||||
|
||||
case tea.MouseMotionMsg:
|
||||
return m.handleHover(msg.X, msg.Y)
|
||||
|
||||
case tea.MouseReleaseMsg:
|
||||
return m.handleRelease(msg.X, msg.Y)
|
||||
```
|
||||
|
||||
Enable mouse via the `View`:
|
||||
|
||||
```go
|
||||
view.MouseMode = tea.MouseModeCellMotion // or MouseModeAll
|
||||
```
|
||||
|
||||
`CellMotion` reports clicks + motion-while-button-pressed (drag). `MouseModeAll` reports motion always — heavier, only when you need hover.
|
||||
|
||||
---
|
||||
|
||||
## Components from `bubbles/v2`
|
||||
|
||||
```go
|
||||
import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/table"
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
```
|
||||
|
||||
All v2 components support `SetVirtualCursor(false)` where they accept text input. Use it for every text input that users might type CJK into — and "might" should be assumed *yes*.
|
||||
|
||||
---
|
||||
|
||||
## Styling — `lipgloss/v2`
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2"
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(lipgloss.Color("62")).
|
||||
Padding(0, 1).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63"))
|
||||
|
||||
rendered := titleStyle.Render("안녕하세요")
|
||||
```
|
||||
|
||||
`lipgloss/v2` width and padding correctly account for CJK display width. v1 did too — this is not a v2-specific fix, just a reminder.
|
||||
|
||||
---
|
||||
|
||||
## Architecture pattern — Model–Update–View
|
||||
|
||||
```
|
||||
+--------------------------------------------+
|
||||
| tea.Program runs the event loop |
|
||||
| |
|
||||
| loop: |
|
||||
| msg <- queue |
|
||||
| model, cmd = model.Update(msg) |
|
||||
| view = model.View() |
|
||||
| render(view) |
|
||||
| if cmd != nil: go run(cmd) -> queue |
|
||||
+--------------------------------------------+
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Model is a value type, not a pointer.** Bubbletea calls `Update` with a value receiver and expects a new value returned. Pointer receivers cause subtle bugs where state mutation leaks across draws.
|
||||
- **`Update` is pure.** No I/O. No goroutines started inline. Any I/O returns a `tea.Cmd` — Bubbletea runs it in a goroutine and feeds the result back as a message.
|
||||
- **`View` is read-only.** It returns a `tea.View` without modifying state.
|
||||
- **`tea.Cmd` is `func() tea.Msg`.** It runs once, returns a message, exits. For repeating work, use `tea.Tick` or a self-resending command.
|
||||
|
||||
```go
|
||||
// One-shot command
|
||||
func loadData() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
data, err := fetch()
|
||||
if err != nil { return errMsg{err} }
|
||||
return dataLoadedMsg{data}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic
|
||||
func tickEvery() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg{t}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Splitting the model — sub-models
|
||||
|
||||
```go
|
||||
type model struct {
|
||||
list list.Model
|
||||
input textinput.Model
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
```
|
||||
|
||||
`tea.Batch` runs commands concurrently. The framework collects their results in the order they arrive.
|
||||
|
||||
When the model exceeds 250 LOC, split by sub-model into separate files:
|
||||
|
||||
```
|
||||
internal/ui/
|
||||
├── model.go # root model orchestration
|
||||
├── list.go # list sub-model state + update + view
|
||||
├── input.go # input sub-model
|
||||
└── spinner.go # spinner sub-model
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing TUI code — `teatest`
|
||||
|
||||
```go
|
||||
import "charm.land/bubbletea/v2/teatest"
|
||||
|
||||
func TestModel_typing_korean_keeps_cursor_in_position(t *testing.T) {
|
||||
// Given
|
||||
m := initial()
|
||||
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
|
||||
|
||||
// When — simulate typing "안녕"
|
||||
tm.Send(tea.KeyPressMsg{Code: '안'})
|
||||
tm.Send(tea.KeyPressMsg{Code: '녕'})
|
||||
|
||||
// Then
|
||||
out := tm.FinalOutput(t)
|
||||
require.Contains(t, string(out), "안녕")
|
||||
// Cursor should be at column 4 (two wide chars = 4 cells)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`teatest` lets you drive the model through synthetic messages and inspect the rendered output. Pair with `autogold` snapshots for full-view regression tests.
|
||||
|
||||
---
|
||||
|
||||
## Common antipatterns
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `tea.Program` with `tea.WithoutSignals()` | Ctrl-C does not work | Default signal handling |
|
||||
| Pointer receivers on Model | Bubbletea expects value semantics | Value receivers, return new model |
|
||||
| `time.Sleep` inside `Update` | Blocks the event loop | `tea.Tick` or async `tea.Cmd` |
|
||||
| `fmt.Println` for debug | Corrupts the rendered output | `tea.Printf` for logging, or write to a file |
|
||||
| `len(s)` for CJK width | Off by 2x | `runewidth.StringWidth(s)` |
|
||||
| `Bubbletea v1` for an app with text input | Korean/Japanese IME breaks | v2 + `SetVirtualCursor(false)` |
|
||||
| Drawing your own `█` block cursor in v2 | Conflicts with `view.Cursor` | Let the terminal handle it |
|
||||
|
||||
---
|
||||
|
||||
## Performance — when v2 starts to crawl
|
||||
|
||||
- **Reduce View frequency.** If the model changes 60 times/sec but the rendered view changes once/sec, gate redraws on a "dirty" flag.
|
||||
- **`viewport.Model` for scrollable content.** Avoid re-rendering thousands of lines on every keystroke.
|
||||
- **`Batch` your commands.** A series of synchronous `tea.Cmd` returns serializes; `tea.Batch` parallelizes.
|
||||
- **Profile with `tea.WithFPS(N)`** to cap repaint rate during development.
|
||||
|
||||
---
|
||||
|
||||
## When NOT to use Bubbletea
|
||||
|
||||
- The app is one prompt + one answer. Use `huh` (also from Charm) — simpler, no Model–Update–View ceremony.
|
||||
- The app is a long-running daemon with occasional status output. Use `slog` to stderr and `tea.Program` only if interactivity becomes necessary.
|
||||
- The app must run as a non-tty subprocess (CI, redirected stdin). `tea.Program` requires a tty for input. Detect via `term.IsTerminal(int(os.Stdin.Fd()))` and fall back to a non-interactive path.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- bubbletea v2 RC: https://github.com/charmbracelet/bubbletea/tree/v2
|
||||
- bubbles v2: https://github.com/charmbracelet/bubbles/tree/v2
|
||||
- lipgloss v2: https://github.com/charmbracelet/lipgloss/tree/v2
|
||||
- bubbletea-wm (IME reference): https://github.com/code-yeongyu/bubbletea-wm
|
||||
- crush CLI (production IME impl): https://github.com/charmbracelet/crush
|
||||
- go-runewidth: https://github.com/mattn/go-runewidth
|
||||
- Unicode East Asian Width: https://www.unicode.org/reports/tr11/
|
||||
@@ -0,0 +1,468 @@
|
||||
# CLI Stack — cobra + slog + caarlos0/env + signal handling
|
||||
|
||||
The canonical Go CLI skeleton. `cobra` is the de facto framework — Kubernetes, Docker CLI, Helm, GitHub CLI, gh, Hugo all use it. Use it.
|
||||
|
||||
---
|
||||
|
||||
## Toolchain
|
||||
|
||||
```bash
|
||||
go install github.com/spf13/cobra-cli@latest
|
||||
cobra-cli init mytool
|
||||
cobra-cli add server
|
||||
cobra-cli add migrate
|
||||
```
|
||||
|
||||
`cobra-cli` scaffolds the `cmd/` package. Edit the result; do not regenerate.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
mytool/
|
||||
├── go.mod
|
||||
├── main.go # ≤ 30 LOC, calls cmd.Execute
|
||||
├── cmd/
|
||||
│ ├── root.go # rootCmd, persistent flags, slog setup
|
||||
│ ├── server.go # `mytool server` subcommand
|
||||
│ ├── migrate.go # `mytool migrate` subcommand
|
||||
│ └── version.go # `mytool version` — auto-injected version
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ └── server/
|
||||
└── Taskfile.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `main.go`
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/your-org/mytool/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := cmd.Execute(ctx); err != nil {
|
||||
slog.Error("fatal", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`signal.NotifyContext` (Go 1.16+) gives every subcommand a ctx that cancels on Ctrl-C. Subcommands plumb the ctx into their workers.
|
||||
|
||||
---
|
||||
|
||||
## `cmd/root.go`
|
||||
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
verbose bool
|
||||
logFormat string
|
||||
configPath string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "mytool",
|
||||
Short: "Short description of mytool",
|
||||
Long: `Long description, prose; cobra wraps it for --help.`,
|
||||
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
||||
return setupLogger()
|
||||
},
|
||||
SilenceUsage: true, // don't print --help on every error
|
||||
SilenceErrors: true, // we log them ourselves in Execute
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
||||
"enable debug logging")
|
||||
rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "text",
|
||||
"log format: text or json")
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "",
|
||||
"path to config file (optional)")
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context) error {
|
||||
return rootCmd.ExecuteContext(ctx)
|
||||
}
|
||||
|
||||
func setupLogger() error {
|
||||
level := slog.LevelInfo
|
||||
if verbose { level = slog.LevelDebug }
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
|
||||
var h slog.Handler
|
||||
switch logFormat {
|
||||
case "json":
|
||||
h = slog.NewJSONHandler(os.Stderr, opts)
|
||||
case "text":
|
||||
h = slog.NewTextHandler(os.Stderr, opts)
|
||||
default:
|
||||
return fmt.Errorf("invalid log-format %q", logFormat)
|
||||
}
|
||||
slog.SetDefault(slog.New(h))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `RunE` / `PersistentPreRunE` (the `E` variants) return errors. Use these; never use `Run` (no error return, encourages `log.Fatal`).
|
||||
- `SilenceUsage: true` + `SilenceErrors: true` together: cobra stops printing the full `--help` on every command failure (the default behavior is rude in production scripts).
|
||||
- `ExecuteContext` (cobra 1.8+) plumbs the ctx into every subcommand's `cmd.Context()`.
|
||||
|
||||
---
|
||||
|
||||
## `cmd/server.go`
|
||||
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/your-org/mytool/internal/server"
|
||||
)
|
||||
|
||||
var (
|
||||
serverAddr string
|
||||
)
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Run the HTTP server",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
ctx := c.Context()
|
||||
slog.InfoContext(ctx, "starting", slog.String("addr", serverAddr))
|
||||
return server.Run(ctx, serverAddr)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
serverCmd.Flags().StringVar(&serverAddr, "addr", ":8080",
|
||||
"listen address")
|
||||
rootCmd.AddCommand(serverCmd)
|
||||
}
|
||||
```
|
||||
|
||||
The subcommand is a thin shim — flags + log line + delegate to `internal/server`. Anything bigger violates the 250-LOC ceiling and belongs in `internal/`.
|
||||
|
||||
---
|
||||
|
||||
## Subcommands with arguments
|
||||
|
||||
```go
|
||||
var migrateUpCmd = &cobra.Command{
|
||||
Use: "up [N]",
|
||||
Short: "Apply N migrations (default: all)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
n := -1 // all
|
||||
if len(args) == 1 {
|
||||
var err error
|
||||
n, err = strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid N: %w", err)
|
||||
}
|
||||
}
|
||||
return migrate.Up(c.Context(), n)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use cobra's argument validators (`cobra.ExactArgs`, `cobra.MaximumNArgs`, `cobra.OnlyValidArgs`). They produce clean help text.
|
||||
|
||||
---
|
||||
|
||||
## Flag types — typed, not strings
|
||||
|
||||
```go
|
||||
// GOOD
|
||||
serverCmd.Flags().DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
||||
serverCmd.Flags().IntVar(&port, "port", 8080, "port")
|
||||
serverCmd.Flags().StringSliceVar(&hosts, "host", nil, "allowed hosts (repeatable)")
|
||||
|
||||
// BAD — manual parsing
|
||||
serverCmd.Flags().StringVar(&timeoutStr, "timeout", "30s", "")
|
||||
// ...then later: time.ParseDuration(timeoutStr)
|
||||
```
|
||||
|
||||
`pflag` (cobra's flag lib) has typed variants for every common type. Use them; the parsing and error messages are free.
|
||||
|
||||
---
|
||||
|
||||
## Bind flags to env vars
|
||||
|
||||
cobra + viper is overkill for env binding. Use `caarlos0/env/v11`:
|
||||
|
||||
```go
|
||||
type ServerOpts struct {
|
||||
Addr string `env:"ADDR" envDefault:":8080"`
|
||||
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
|
||||
}
|
||||
|
||||
var opts ServerOpts
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
||||
// 1. Parse env first.
|
||||
if err := env.Parse(&opts); err != nil { return err }
|
||||
// 2. Flags override env if explicitly set.
|
||||
if c.Flags().Changed("addr") {
|
||||
opts.Addr, _ = c.Flags().GetString("addr")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
return server.Run(c.Context(), opts)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
serverCmd.Flags().String("addr", "", "listen address (env: ADDR)")
|
||||
serverCmd.Flags().Duration("timeout", 0, "request timeout (env: TIMEOUT)")
|
||||
rootCmd.AddCommand(serverCmd)
|
||||
}
|
||||
```
|
||||
|
||||
Precedence: **flag (if set) > env > default**. Document the env var in the flag usage string.
|
||||
|
||||
---
|
||||
|
||||
## Version subcommand — build-injected
|
||||
|
||||
```go
|
||||
// cmd/version.go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Set by -ldflags at build time, falls back to debug.BuildInfo.
|
||||
var (
|
||||
version = ""
|
||||
commit = ""
|
||||
date = ""
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version",
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
v, c2, d := resolveVersion()
|
||||
fmt.Printf("mytool %s (commit %s, built %s)\n", v, c2, d)
|
||||
},
|
||||
}
|
||||
|
||||
func resolveVersion() (string, string, string) {
|
||||
if version != "" { return version, commit, date }
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok { return "dev", "unknown", "unknown" }
|
||||
|
||||
var vcs, hash, time string
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision": hash = s.Value
|
||||
case "vcs.time": time = s.Value
|
||||
case "vcs": vcs = s.Value
|
||||
}
|
||||
}
|
||||
return info.Main.Version, hash, time + " (" + vcs + ")"
|
||||
}
|
||||
|
||||
func init() { rootCmd.AddCommand(versionCmd) }
|
||||
```
|
||||
|
||||
Build with version injection:
|
||||
|
||||
```bash
|
||||
go build \
|
||||
-ldflags="-X 'github.com/your-org/mytool/cmd.version=v1.2.3' -X 'github.com/your-org/mytool/cmd.commit=$(git rev-parse --short HEAD)' -X 'github.com/your-org/mytool/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
|
||||
-o bin/mytool ./
|
||||
```
|
||||
|
||||
The `debug.BuildInfo` fallback means a `go install`'d binary also has version info — no manual `-ldflags` needed.
|
||||
|
||||
---
|
||||
|
||||
## Shell completions
|
||||
|
||||
```go
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion",
|
||||
Args: cobra.ExactValidArgs(1),
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
DisableFlagsInUseLine: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "bash": return rootCmd.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh": return rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish": return rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell": return rootCmd.GenPowerShellCompletion(os.Stdout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() { rootCmd.AddCommand(completionCmd) }
|
||||
```
|
||||
|
||||
User:
|
||||
|
||||
```bash
|
||||
mytool completion zsh > "${fpath[1]}/_mytool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interactive prompts — `huh` from charm
|
||||
|
||||
For prompts/forms (`Are you sure?`, "Pick an environment", multi-field forms):
|
||||
|
||||
```go
|
||||
import "github.com/charmbracelet/huh"
|
||||
|
||||
var confirm bool
|
||||
err := huh.NewConfirm().
|
||||
Title("Apply migrations to PRODUCTION?").
|
||||
Affirmative("Yes, do it").
|
||||
Negative("Abort").
|
||||
Value(&confirm).
|
||||
Run()
|
||||
```
|
||||
|
||||
`huh` replaces `survey` (which is no longer maintained). It composes with `lipgloss` for styling.
|
||||
|
||||
---
|
||||
|
||||
## Progress / spinners
|
||||
|
||||
```go
|
||||
import "github.com/charmbracelet/huh/spinner"
|
||||
|
||||
err := spinner.New().Title("Fetching...").Action(func() {
|
||||
// long-running work
|
||||
}).Run()
|
||||
```
|
||||
|
||||
For determinate progress (downloads, batch processing), use `vbauerster/mpb/v8`:
|
||||
|
||||
```go
|
||||
import "github.com/vbauerster/mpb/v8"
|
||||
|
||||
p := mpb.New(mpb.WithWidth(60))
|
||||
bar := p.AddBar(int64(total), /* decorators */)
|
||||
for i := 0; i < total; i++ {
|
||||
work()
|
||||
bar.Increment()
|
||||
}
|
||||
p.Wait()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output — JSON vs text
|
||||
|
||||
Honor `--output json` for any CLI that scripts will parse:
|
||||
|
||||
```go
|
||||
var outputFmt string
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&outputFmt, "output", "text",
|
||||
"output format: text or json")
|
||||
|
||||
func render(v any) error {
|
||||
switch outputFmt {
|
||||
case "json":
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
case "text":
|
||||
return renderText(v)
|
||||
default:
|
||||
return fmt.Errorf("invalid --output %q", outputFmt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `text` format uses `lipgloss` tables or `aquasecurity/table` for nicely-aligned columns. The `json` format is for `jq`-style piping.
|
||||
|
||||
---
|
||||
|
||||
## Error semantics
|
||||
|
||||
- Return errors from `RunE`. Cobra catches them and the `Execute` wrapper logs + exits non-zero.
|
||||
- `os.Exit(1)` should appear **only in `main.go`**. Anywhere else means a subcommand cannot be tested.
|
||||
- For graceful early termination ("user cancelled"), return a sentinel and check it in `Execute`:
|
||||
```go
|
||||
var ErrCancelled = errors.New("cancelled by user")
|
||||
// ... return ErrCancelled
|
||||
// in main:
|
||||
if errors.Is(err, cmd.ErrCancelled) { os.Exit(130) } // 128 + SIGINT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing CLI commands
|
||||
|
||||
```go
|
||||
func TestServerCmd_runs_with_default_addr(t *testing.T) {
|
||||
// Given
|
||||
buf := &bytes.Buffer{}
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"server", "--addr", ":0"})
|
||||
|
||||
// When
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
err := rootCmd.ExecuteContext(ctx)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), "starting")
|
||||
}
|
||||
```
|
||||
|
||||
`SetArgs` + `ExecuteContext` is the canonical pattern. Bind a ctx with a short deadline for tests that would otherwise block.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- cobra docs: https://github.com/spf13/cobra/blob/main/site/content/user_guide.md
|
||||
- pflag: https://github.com/spf13/pflag
|
||||
- huh: https://github.com/charmbracelet/huh
|
||||
- caarlos0/env: https://github.com/caarlos0/env
|
||||
- signal.NotifyContext: https://pkg.go.dev/os/signal#NotifyContext
|
||||
@@ -0,0 +1,362 @@
|
||||
# Concurrency
|
||||
|
||||
Goroutines, context, errgroup, channels, locks, and the discipline that keeps them from leaking. Go makes concurrency *easy to start* and *easy to get wrong*. This document is the boring rule set.
|
||||
|
||||
---
|
||||
|
||||
## The four non-negotiables
|
||||
|
||||
1. **`ctx context.Context` is the first parameter of every public function that does I/O or can be cancelled.**
|
||||
2. **No goroutine without a shutdown path.** Every `go` keyword must answer "how does this stop?".
|
||||
3. **`-race` on every test run.** The `Taskfile.yml` and CI both enforce it.
|
||||
4. **`goleak` in `TestMain`** for every package that spawns goroutines. Catches leaks the race detector cannot.
|
||||
|
||||
---
|
||||
|
||||
## `context.Context` — the cancellation backbone
|
||||
|
||||
```go
|
||||
// GOOD — ctx as first param, propagated through
|
||||
func (s *UserService) Create(ctx context.Context, email Email) (User, error) {
|
||||
user, err := s.store.Insert(ctx, email)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
if err := s.notifier.Welcome(ctx, user); err != nil {
|
||||
return User{}, fmt.Errorf("notify: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// BAD — creates a fresh ctx, breaks request cancellation
|
||||
func (s *UserService) Create(email Email) (User, error) {
|
||||
ctx := context.Background() // ← contextcheck linter rejects this
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The `contextcheck` linter (enabled in `golangci-strict.md`) refuses any function that has `ctx context.Context` available but uses `context.Background()` instead.
|
||||
|
||||
### `context.Value` — use sparingly
|
||||
|
||||
```go
|
||||
// Typed key — never use a bare string
|
||||
type ctxKey struct{ name string }
|
||||
var requestIDKey = ctxKey{"request_id"}
|
||||
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey, id)
|
||||
}
|
||||
|
||||
func RequestID(ctx context.Context) string {
|
||||
v, _ := ctx.Value(requestIDKey).(string)
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- Keys are unexported struct types, not strings. Prevents collisions across packages.
|
||||
- `context.Value` is for *request-scoped metadata* (request ID, auth subject, trace span), NEVER for application-scoped dependencies.
|
||||
- Dependencies (loggers, DB pools, config) go in your service struct, not in `context.Value`.
|
||||
|
||||
### `WithTimeout` / `WithCancel` — always pair with `defer cancel()`
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel() // ← MUST be deferred. fatcontext linter catches misses.
|
||||
|
||||
if err := slow(ctx); err != nil { ... }
|
||||
```
|
||||
|
||||
Forgetting `defer cancel()` leaks a context goroutine until the parent expires — the `lostcancel` vet check catches it.
|
||||
|
||||
---
|
||||
|
||||
## `errgroup` — the structured concurrency primitive
|
||||
|
||||
`golang.org/x/sync/errgroup` is Go's answer to Python's `asyncio.TaskGroup` or Rust's `JoinSet`. Use it instead of raw `go` for any group of related goroutines.
|
||||
|
||||
```go
|
||||
import "golang.org/x/sync/errgroup"
|
||||
|
||||
func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(8) // concurrency cap — leave unbounded = production outage
|
||||
|
||||
results := make([][]byte, len(urls))
|
||||
for i, u := range urls {
|
||||
g.Go(func() error {
|
||||
body, err := fetch(ctx, u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", u, err)
|
||||
}
|
||||
results[i] = body
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- `WithContext(parent)` returns a child ctx that gets cancelled on **first non-nil error**. All in-flight goroutines see `ctx.Done()` and bail.
|
||||
- `SetLimit(n)` blocks `g.Go(...)` when the in-flight count hits `n`. **Always set this.** Unbounded fan-out is how services die.
|
||||
- `g.Wait()` returns the **first** non-nil error. Others are dropped. If you need all errors, accumulate them manually:
|
||||
```go
|
||||
var mu sync.Mutex
|
||||
var errs []error
|
||||
// inside g.Go:
|
||||
// mu.Lock(); errs = append(errs, err); mu.Unlock()
|
||||
// after Wait, errors.Join(errs...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Goroutine leaks — `goleak`
|
||||
|
||||
```go
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
```
|
||||
|
||||
This single line at the top of `*_test.go` runs goleak's check after every test in the package. If a test leaks a goroutine, the run fails — pointing at which goroutine.
|
||||
|
||||
**The bug it catches**: starting a goroutine in `setUp` and never joining it. Common in DB connection pools, background workers, ticker loops. The race detector does NOT catch this.
|
||||
|
||||
If you have a known long-lived goroutine (a singleton background worker, a metrics exporter), use `goleak.IgnoreTopFunction`:
|
||||
|
||||
```go
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/prometheus/client_golang/prometheus.(*Registry).Push"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channels — the rules that hold
|
||||
|
||||
### Direction
|
||||
|
||||
```go
|
||||
// GOOD — direction in signatures
|
||||
func produce(out chan<- Item)
|
||||
func consume(in <-chan Item)
|
||||
func pipeline(in <-chan Item, out chan<- Item)
|
||||
```
|
||||
|
||||
Direction restricts misuse. A consumer cannot close the producer's channel.
|
||||
|
||||
### Closing
|
||||
|
||||
- **The sender closes.** Always. Never the receiver, never multiple senders.
|
||||
- **Multiple senders → use a `sync.WaitGroup` + one closer.**
|
||||
- **Closing a closed channel panics.** Closing a `nil` channel panics. Sending on a closed channel panics. Receiving from a closed channel returns zero value with `ok = false`.
|
||||
|
||||
```go
|
||||
// Canonical fan-in: multiple producers, one closer
|
||||
func fanIn(ctx context.Context, sources ...<-chan Item) <-chan Item {
|
||||
out := make(chan Item)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(sources))
|
||||
for _, src := range sources {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range src {
|
||||
select {
|
||||
case out <- item:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() { wg.Wait(); close(out) }()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
### Selecting
|
||||
|
||||
```go
|
||||
select {
|
||||
case msg := <-incoming:
|
||||
handle(msg)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
return ErrTimeout
|
||||
}
|
||||
```
|
||||
|
||||
- `time.After` allocates a timer each call — fine for occasional selects, **NOT for hot loops**. Use `time.NewTimer` + `timer.Reset` for repeat selects.
|
||||
- A `default:` case makes `select` non-blocking. Use deliberately, not by accident.
|
||||
|
||||
### Buffered vs unbuffered
|
||||
|
||||
- **Unbuffered** (`make(chan T)`) = synchronous handoff. Sender blocks until receiver is ready. Use for *coordination*.
|
||||
- **Buffered** (`make(chan T, n)`) = asynchronous up to `n`. Use for *decoupling producer rate from consumer rate*.
|
||||
|
||||
A buffered channel of size 1 acts as a **non-blocking signal**:
|
||||
|
||||
```go
|
||||
ready := make(chan struct{}, 1)
|
||||
// Producer
|
||||
select {
|
||||
case ready <- struct{}{}: // signal once, non-blocking
|
||||
default: // already signaled, skip
|
||||
}
|
||||
// Consumer
|
||||
<-ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Locks — the pyramid
|
||||
|
||||
```
|
||||
Highest level (preferred)
|
||||
channels (message passing — "share memory by communicating")
|
||||
errgroup / wait group
|
||||
|
||||
sync.RWMutex (many readers, occasional writer)
|
||||
sync.Mutex (mutual exclusion)
|
||||
|
||||
atomic.Int64 / atomic.Pointer (single-word lock-free)
|
||||
|
||||
Lowest level (rare)
|
||||
unsafe.Pointer + barriers (custom lock-free; needs -race AND review)
|
||||
```
|
||||
|
||||
### `sync.Mutex` — embed, don't expose
|
||||
|
||||
```go
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]Entry
|
||||
}
|
||||
|
||||
func (c *Cache) Get(key string) (Entry, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
e, ok := c.items[key]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, e Entry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[key] = e
|
||||
}
|
||||
```
|
||||
|
||||
- `sync.Mutex` is **not** copyable. The `copylocks` vet check catches `var c2 = c1` where `c1` has a mutex.
|
||||
- Always `defer mu.Unlock()` immediately after `Lock()`. Forgetting is the #1 deadlock cause.
|
||||
- Never call user code (callbacks, listener notifications) while holding the lock. Drop the lock, snapshot the data, release, then call out.
|
||||
|
||||
### `sync.OnceValue` / `sync.OnceFunc` (Go 1.21+)
|
||||
|
||||
Replacement for `sync.Once` for typed lazy init:
|
||||
|
||||
```go
|
||||
var loadConfig = sync.OnceValue(func() Config {
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil { panic(err) }
|
||||
return cfg
|
||||
})
|
||||
|
||||
func handler() { cfg := loadConfig(); ... }
|
||||
```
|
||||
|
||||
Type-safe, no `sync.Once` + global variable boilerplate.
|
||||
|
||||
### Atomics — the typed API only
|
||||
|
||||
```go
|
||||
// Go 1.19+ — use the typed atomic.* family
|
||||
var counter atomic.Int64
|
||||
counter.Add(1)
|
||||
n := counter.Load()
|
||||
|
||||
// NEVER — the old function-style is type-unsafe
|
||||
atomic.AddInt64(&counter, 1) // ← rejected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Time — inject a clock for testability
|
||||
|
||||
```go
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type realClock struct{}
|
||||
func (realClock) Now() time.Time { return time.Now() }
|
||||
|
||||
type Service struct {
|
||||
clock Clock
|
||||
}
|
||||
|
||||
// Tests
|
||||
import "github.com/benbjohnson/clock"
|
||||
fake := clock.NewMock()
|
||||
fake.Set(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
svc := &Service{clock: fake}
|
||||
```
|
||||
|
||||
**Never call `time.Now()` in domain or service code.** The `time` package becomes a hidden dependency — tests become flaky, retries become time-of-day-dependent, expirations cannot be tested.
|
||||
|
||||
`time.Sleep` in production code is a code smell. Use:
|
||||
- `time.NewTicker` for periodic work (and a `<-ctx.Done()` exit).
|
||||
- `time.NewTimer` for one-shot delays.
|
||||
- `time.After` ONLY in select statements, ONLY in non-hot paths.
|
||||
|
||||
---
|
||||
|
||||
## Race detector — non-negotiable in CI
|
||||
|
||||
```bash
|
||||
go test -race -shuffle=on -count=1 ./...
|
||||
```
|
||||
|
||||
- `-race` instruments memory accesses; catches data races at runtime. ~10x slow-down — acceptable for tests, not production.
|
||||
- `-shuffle=on` randomizes test order; catches hidden ordering dependencies.
|
||||
- `-count=1` defeats the test cache. Without it, "passing" might mean "ran 3 weeks ago".
|
||||
|
||||
If a test ONLY fails under `-race`, the bug is real. Don't disable the test; fix the race.
|
||||
|
||||
---
|
||||
|
||||
## Common antipatterns
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `go func() { ... }()` with no `ctx` plumbing | Leaks on shutdown | `errgroup.WithContext` or pass ctx |
|
||||
| Bare `time.Sleep(d)` in production | Untestable, blocks | `time.NewTimer` + select with `ctx.Done()` |
|
||||
| Channel of `interface{}` | Loses type | Typed channel; use sealed interface if variants needed |
|
||||
| `sync.Mutex` in a struct passed by value | Locked copies, undefined behavior | Embed in pointer-receiver type; copylocks catches it |
|
||||
| Locking around an entire request handler | Serializes the whole API | Lock only the smallest critical section |
|
||||
| `for { select { ... } }` without `<-ctx.Done()` | Cannot stop | Add ctx case in every long-lived select |
|
||||
| `sync.WaitGroup.Add(1)` inside the goroutine | Race: Wait can return before Add | Add **before** `go` |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Go memory model: https://go.dev/ref/mem
|
||||
- `errgroup` package: https://pkg.go.dev/golang.org/x/sync/errgroup
|
||||
- `goleak`: https://github.com/uber-go/goleak
|
||||
- "Go concurrency patterns" (Pike): https://go.dev/blog/pipelines
|
||||
- Sync.OnceValue blog: https://go.dev/blog/synctest (1.24+ note: `testing/synctest` for time-controlled tests is now experimental)
|
||||
@@ -0,0 +1,329 @@
|
||||
# Data Modeling — Three Layers of Validation
|
||||
|
||||
Go has no Pydantic. Go has no Zod. **You do not need them**, but only if you wire three layers correctly. This document is the canonical pattern.
|
||||
|
||||
## The three layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HTTP / RPC / CLI │
|
||||
│ Raw bytes, strings, untrusted input │
|
||||
│ │
|
||||
│ Layer 1: validator/v10 (struct tags) ◄── parse-once │
|
||||
│ OR protovalidate (proto) │
|
||||
│ │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ raw req → domain.X
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Domain (internal/domain) │
|
||||
│ │
|
||||
│ Layer 2: Smart constructors + unexported fields │
|
||||
│ NewEmail(s) → (Email, error) │
|
||||
│ NewUserID(s) → (UserID, error) │
|
||||
│ │
|
||||
│ Once inside this layer, NO further validation. │
|
||||
│ The types prove correctness. │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ domain.X (proven valid)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Storage (internal/store) │
|
||||
│ │
|
||||
│ Layer 3: sqlc-generated row structs ↔ domain types │
|
||||
│ Hand-written mappers, NOT struct tags │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each layer parses once, into the next layer's types. **A function in the domain layer should never receive a raw string and validate it.** If it does, the boundary above failed.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: HTTP boundary — `go-playground/validator/v10`
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// CreateUserRequest is the wire format. Tags drive validation.
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
|
||||
Age int `json:"age" binding:"required,gte=13,lte=130"`
|
||||
Country string `json:"country" binding:"required,iso3166_1_alpha2"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// validator returns ValidationErrors with field-by-field detail
|
||||
var vErr validator.ValidationErrors
|
||||
if errors.As(err, &vErr) {
|
||||
c.JSON(400, gin.H{"errors": fieldErrors(vErr)})
|
||||
return
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cross into domain — single point of failure
|
||||
email, err := domain.NewEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
username, err := domain.NewUsername(req.Username)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.Create(c.Request.Context(), email, username, req.Age)
|
||||
if err != nil {
|
||||
h.writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(201, user)
|
||||
}
|
||||
|
||||
func fieldErrors(vErr validator.ValidationErrors) map[string]string {
|
||||
out := make(map[string]string, len(vErr))
|
||||
for _, fe := range vErr {
|
||||
out[fe.Field()] = fe.Tag() + "(" + fe.Param() + ")"
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
**Tag reference — the tags you actually use**:
|
||||
|
||||
| Tag | Meaning |
|
||||
|---|---|
|
||||
| `required` | Non-zero value |
|
||||
| `omitempty` (json) | Skip if zero |
|
||||
| `min=N` / `max=N` | Length (strings/slices) or value (numbers) |
|
||||
| `gte=N` / `lte=N` / `gt=N` / `lt=N` | Numeric comparison |
|
||||
| `email` | RFC 5322-ish email |
|
||||
| `url` | Valid URL |
|
||||
| `uuid` / `uuid4` / `uuid7` | UUID format |
|
||||
| `alphanum` / `alpha` / `numeric` | Character class |
|
||||
| `iso3166_1_alpha2` | Country code (US, KR, JP) |
|
||||
| `iso4217` | Currency code (USD, KRW) |
|
||||
| `oneof=a b c` | Enum of literal values |
|
||||
| `dive` | Apply rules to each element of slice/map |
|
||||
| `eqfield=Field` | Cross-field equality (e.g., password confirm) |
|
||||
|
||||
### Custom validators — register at startup
|
||||
|
||||
```go
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
_ = v.RegisterValidation("strongpassword", validateStrongPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStrongPassword(fl validator.FieldLevel) bool {
|
||||
s := fl.Field().String()
|
||||
return len(s) >= 12 && hasUpper(s) && hasDigit(s) && hasSymbol(s)
|
||||
}
|
||||
```
|
||||
|
||||
Use sparingly. Most domain rules belong in smart constructors, not validators.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Domain — smart constructors
|
||||
|
||||
Covered in detail in `type-patterns.md`. Recap:
|
||||
|
||||
```go
|
||||
package domain
|
||||
|
||||
type Username struct{ raw string }
|
||||
|
||||
func NewUsername(s string) (Username, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 3 || len(s) > 32 {
|
||||
return Username{}, ErrInvalidUsername
|
||||
}
|
||||
if !isAlphanum(s) {
|
||||
return Username{}, ErrInvalidUsername
|
||||
}
|
||||
return Username{raw: s}, nil
|
||||
}
|
||||
|
||||
func (u Username) String() string { return u.raw }
|
||||
```
|
||||
|
||||
**Rule**: every domain type that has invariants has:
|
||||
|
||||
1. An unexported field holding the raw form.
|
||||
2. A `New<Type>(raw) (<Type>, error)` constructor as the sole entry point.
|
||||
3. A `String() string` for printing.
|
||||
4. `MarshalJSON` / `UnmarshalJSON` if it crosses a JSON boundary outside HTTP handlers (e.g., logging payloads, queue messages).
|
||||
5. Optionally: `Scan` and `Value` for `database/sql` interop (rare with sqlc).
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Storage — sqlc rows ↔ domain types
|
||||
|
||||
sqlc generates row structs from `.sql` files. **Do not put validation tags on them.** Map between sqlc rows and domain types explicitly:
|
||||
|
||||
```go
|
||||
// internal/store/user_store.go
|
||||
package store
|
||||
|
||||
import "myservice/internal/domain"
|
||||
|
||||
func (s *UserStore) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
|
||||
row, err := s.q.GetUser(ctx, string(id))
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return rowToUser(row)
|
||||
}
|
||||
|
||||
func rowToUser(r sqlc.UserRow) (domain.User, error) {
|
||||
email, err := domain.NewEmail(r.Email)
|
||||
if err != nil {
|
||||
// DB invariant broken — this is a programmer error, not a user error
|
||||
return domain.User{}, fmt.Errorf("db invariant: invalid email for user %s: %w", r.ID, err)
|
||||
}
|
||||
username, err := domain.NewUsername(r.Username)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("db invariant: invalid username: %w", err)
|
||||
}
|
||||
return domain.User{
|
||||
ID: domain.UserID(r.ID),
|
||||
Email: email,
|
||||
Username: username,
|
||||
Created: r.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The mapping is verbose. **That is the point.** Each field is a deliberate choice; refactors flag every site.
|
||||
|
||||
---
|
||||
|
||||
## Discriminated unions (sum types) at the boundary
|
||||
|
||||
When a wire payload has variants (e.g., `{"type": "user.created", ...}` vs `{"type": "user.deleted", ...}`):
|
||||
|
||||
```go
|
||||
// Wire DTO with raw discriminator
|
||||
type EventDTO struct {
|
||||
Type string `json:"type" binding:"required,oneof=created deleted updated"`
|
||||
Payload json.RawMessage `json:"payload" binding:"required"`
|
||||
}
|
||||
|
||||
// Parse into the sealed domain type
|
||||
func ParseEvent(dto EventDTO) (event.Event, error) {
|
||||
switch dto.Type {
|
||||
case "created":
|
||||
var c event.Created
|
||||
if err := json.Unmarshal(dto.Payload, &c); err != nil {
|
||||
return nil, fmt.Errorf("decode created: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
case "deleted":
|
||||
var d event.Deleted
|
||||
if err := json.Unmarshal(dto.Payload, &d); err != nil {
|
||||
return nil, fmt.Errorf("decode deleted: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
case "updated":
|
||||
var u event.Updated
|
||||
if err := json.Unmarshal(dto.Payload, &u); err != nil {
|
||||
return nil, fmt.Errorf("decode updated: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown event type %q", dto.Type)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `exhaustive` linter on the switch + the `oneof` validation tag together cover both "unknown type" and "unhandled variant".
|
||||
|
||||
---
|
||||
|
||||
## Enums — typed string consts, not iota
|
||||
|
||||
```go
|
||||
// GOOD — string-based, JSON-serializes correctly, debuggable
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusActive Status = "active"
|
||||
StatusClosed Status = "closed"
|
||||
)
|
||||
|
||||
func (s Status) IsValid() bool {
|
||||
switch s {
|
||||
case StatusPending, StatusActive, StatusClosed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Status) UnmarshalJSON(data []byte) error {
|
||||
var raw string
|
||||
if err := json.Unmarshal(data, &raw); err != nil { return err }
|
||||
parsed := Status(raw)
|
||||
if !parsed.IsValid() { return fmt.Errorf("invalid status %q", raw) }
|
||||
*s = parsed
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Never use `iota` enums for anything that crosses a wire boundary.** They serialize as integers, which (a) breaks debuggability, (b) makes reordering enum values a silent breaking change.
|
||||
|
||||
Use the validator tag `binding:"oneof=pending active closed"` to enforce at the HTTP boundary.
|
||||
|
||||
---
|
||||
|
||||
## Nullable fields — `*T` vs sentinel
|
||||
|
||||
Three choices, in order of preference:
|
||||
|
||||
1. **Sentinel zero value**: `Age int` with `0` meaning "unknown". Works when zero is genuinely unreachable as a valid value.
|
||||
2. **`sql.Null<T>`** for DB columns: `sql.NullString`, `sql.NullInt64`, `sql.NullTime`. sqlc generates these for nullable columns.
|
||||
3. **`*T`**: only when you need to distinguish "not provided" from "set to zero" in a JSON payload (PATCH semantics).
|
||||
|
||||
```go
|
||||
// PATCH payload — `*string` discriminates absent vs empty
|
||||
type UpdateUserRequest struct {
|
||||
Email *string `json:"email,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Avoid `*T` in domain types — it bloats every consumer with nil checks. Keep `*T` at the boundary, unwrap on the way in.
|
||||
|
||||
---
|
||||
|
||||
## Common AI-generated antipatterns this rejects
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `func handle(req map[string]any)` | No types, no validation | Define a struct, parse with `validator` |
|
||||
| `if email != "" { ... }` inside domain | Validation in the wrong layer | Make `email Email`, no check needed |
|
||||
| `type Status int` with `iota` for wire field | Silent breaking on reorder | `type Status string` with const literals |
|
||||
| Struct tags `json:"email,string"` (the `,string` coercion) | Magic coercion hides bad input | Strict parsing, fail-fast |
|
||||
| `json.Unmarshal` then range-check after | Two-step "validate after parse" | Use `validator` tags or custom `UnmarshalJSON` |
|
||||
| Reusing handler DTO as the domain type | Couples wire format to business logic | Two distinct types, explicit mapping |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- go-playground/validator: https://github.com/go-playground/validator
|
||||
- gin binding internals: https://github.com/gin-gonic/gin/blob/master/binding/json.go
|
||||
- Parse, don't validate: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
|
||||
- sqlc with custom types: https://docs.sqlc.dev/en/latest/howto/overrides.html
|
||||
@@ -0,0 +1,359 @@
|
||||
# Error Handling
|
||||
|
||||
Typed errors, wrap chains, `errors.Is` / `errors.As`, no panic in libraries, resource cleanup. Go errors look simple and are full of footguns. This document is the canonical set of moves.
|
||||
|
||||
---
|
||||
|
||||
## The five rules
|
||||
|
||||
1. **Every error is wrapped on the way up, with `%w`, with context.** Never `return err` from a non-trivial site.
|
||||
2. **Compare with `errors.Is`, not `==`.** Wrap chains break `==`. The `errorlint` linter forbids `==` on errors.
|
||||
3. **Cast with `errors.As`, not type assertion.** Same reason.
|
||||
4. **`panic` is reserved for programmer errors.** Library code never panics on user input or environment failures. Use `(T, error)`.
|
||||
5. **Resources released via `defer` immediately after acquisition.** No "I'll add it later".
|
||||
|
||||
---
|
||||
|
||||
## Sentinel errors — for invariant programmatic checks
|
||||
|
||||
```go
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidEmail = errors.New("domain: invalid email")
|
||||
ErrInvalidPhone = errors.New("domain: invalid phone")
|
||||
ErrInvalidAge = errors.New("domain: invalid age")
|
||||
)
|
||||
|
||||
func NewEmail(s string) (Email, error) {
|
||||
if !emailRe.MatchString(s) {
|
||||
return Email{}, fmt.Errorf("email %q: %w", s, ErrInvalidEmail)
|
||||
}
|
||||
return Email{raw: strings.ToLower(s)}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Caller branches on identity:
|
||||
|
||||
```go
|
||||
email, err := domain.NewEmail(input)
|
||||
if errors.Is(err, domain.ErrInvalidEmail) {
|
||||
return c.JSON(400, gin.H{"error": "email format"})
|
||||
}
|
||||
```
|
||||
|
||||
`errors.Is` walks the wrap chain. `err == domain.ErrInvalidEmail` would have failed because `fmt.Errorf` wrapped it.
|
||||
|
||||
---
|
||||
|
||||
## Typed errors — when you need structured data
|
||||
|
||||
When callers need fields off the error (the offending value, the failing field name, the upstream HTTP status):
|
||||
|
||||
```go
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Value string
|
||||
Rule string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation: %s=%q failed %s", e.Field, e.Value, e.Rule)
|
||||
}
|
||||
|
||||
// Optional: identity sentinel for errors.Is comparisons
|
||||
var ErrValidation = errors.New("validation")
|
||||
|
||||
func (e *ValidationError) Is(target error) bool {
|
||||
return target == ErrValidation
|
||||
}
|
||||
```
|
||||
|
||||
Caller:
|
||||
|
||||
```go
|
||||
err := svc.Save(ctx, user)
|
||||
|
||||
var vErr *ValidationError
|
||||
if errors.As(err, &vErr) {
|
||||
// vErr.Field, vErr.Rule are available
|
||||
c.JSON(400, gin.H{"field": vErr.Field, "rule": vErr.Rule})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**`errors.As` requires a non-nil pointer-to-pointer.** Almost always the type is `*ConcreteError`. Forgetting the leading `*` is the most common bug here.
|
||||
|
||||
---
|
||||
|
||||
## Wrapping — `%w` is mandatory
|
||||
|
||||
```go
|
||||
// BAD — drops context
|
||||
return err
|
||||
|
||||
// BAD — drops the error chain (errors.Is/As stops working)
|
||||
return fmt.Errorf("failed to save user: %v", err)
|
||||
|
||||
// GOOD — preserves chain via %w
|
||||
return fmt.Errorf("save user %s: %w", userID, err)
|
||||
```
|
||||
|
||||
The `errorlint` linter catches `%v` where `%w` was meant. **Wrap once per layer**, with the minimum useful context:
|
||||
|
||||
```
|
||||
api/handler: "create user request: %w"
|
||||
service: "validate inputs: %w"
|
||||
domain: "email %q: %w"
|
||||
```
|
||||
|
||||
Each frame adds one fact, not a duplicate. The top-level error message reads as a path: `create user request: validate inputs: email "foo": domain: invalid email`.
|
||||
|
||||
### `errors.Join` — multiple errors at once
|
||||
|
||||
```go
|
||||
// Validate all fields, collect all errors
|
||||
var errs []error
|
||||
if _, err := NewEmail(req.Email); err != nil {
|
||||
errs = append(errs, fmt.Errorf("email: %w", err))
|
||||
}
|
||||
if _, err := NewUsername(req.Username); err != nil {
|
||||
errs = append(errs, fmt.Errorf("username: %w", err))
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
```
|
||||
|
||||
`errors.Is` still walks each joined error. Use when reporting batch validation, not for "wrap two unrelated errors".
|
||||
|
||||
---
|
||||
|
||||
## Panics — when allowed, when banned
|
||||
|
||||
**Banned**:
|
||||
|
||||
- Anywhere a `(T, error)` could be returned.
|
||||
- Inside HTTP handlers (gin's `Recovery` middleware catches them, but you've already lost the error context).
|
||||
- Inside any goroutine that survives request lifetime.
|
||||
|
||||
**Allowed** (with documentation):
|
||||
|
||||
- Map literal init at package level: `var statusNames = map[Status]string{...}` followed by a `func init()` that panics if a const has no name. Catches the bug at startup, not runtime.
|
||||
- The `must*` convention for genuinely unrecoverable startup:
|
||||
```go
|
||||
func MustParseURL(s string) *url.URL {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil { panic(err) }
|
||||
return u
|
||||
}
|
||||
// Use only with literals known at compile time:
|
||||
var defaultAPI = MustParseURL("https://api.example.com")
|
||||
```
|
||||
- `default:` case of an exhaustive sealed-interface switch — see `type-patterns.md`.
|
||||
|
||||
The `revive` linter rule `error-return` will flag suspect panic sites; treat them as bugs.
|
||||
|
||||
---
|
||||
|
||||
## `defer` for resources — the only safe pattern
|
||||
|
||||
```go
|
||||
func writeReport(path string) (err error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close %s: %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `defer f.Close()` immediately after `os.Create` — never further down.
|
||||
- Named return `(err error)` so the deferred close can mutate it on close failure.
|
||||
- `bodyclose` linter catches missed `defer resp.Body.Close()` for HTTP responses.
|
||||
- `sqlclosecheck` linter catches missed `defer rows.Close()` for SQL.
|
||||
|
||||
### `errors.Join` for multi-stage cleanup
|
||||
|
||||
```go
|
||||
func process(path string) (err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil { return err }
|
||||
defer func() {
|
||||
err = errors.Join(err, f.Close())
|
||||
}()
|
||||
// ... use f ...
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
When both the main operation AND `Close` can fail, `errors.Join` reports both without dropping either.
|
||||
|
||||
---
|
||||
|
||||
## HTTP error responses — a single funnel
|
||||
|
||||
Build one helper, route all handler errors through it:
|
||||
|
||||
```go
|
||||
package httperr
|
||||
|
||||
type APIError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
|
||||
|
||||
var (
|
||||
NotFound = &APIError{Status: 404, Code: "not_found", Message: "resource not found"}
|
||||
Unauthorized = &APIError{Status: 401, Code: "unauthorized", Message: "unauthorized"}
|
||||
BadRequest = &APIError{Status: 400, Code: "bad_request", Message: "bad request"}
|
||||
Internal = &APIError{Status: 500, Code: "internal", Message: "internal error"}
|
||||
)
|
||||
|
||||
// Wrap a domain error into an API error.
|
||||
func From(err error) *APIError {
|
||||
if err == nil { return nil }
|
||||
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) { return apiErr }
|
||||
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidEmail),
|
||||
errors.Is(err, domain.ErrInvalidUsername):
|
||||
return &APIError{Status: 400, Code: "validation", Message: err.Error()}
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return NotFound
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
return Unauthorized
|
||||
default:
|
||||
// unknown — log full chain, return generic
|
||||
slog.Error("unmapped error", slog.Any("err", err))
|
||||
return Internal
|
||||
}
|
||||
}
|
||||
|
||||
func Write(c *gin.Context, err error) {
|
||||
apiErr := From(err)
|
||||
c.JSON(apiErr.Status, apiErr)
|
||||
}
|
||||
```
|
||||
|
||||
Handlers become trivial:
|
||||
|
||||
```go
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
user, err := h.svc.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(201, user)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## errgroup — error propagation across goroutines
|
||||
|
||||
```go
|
||||
import "golang.org/x/sync/errgroup"
|
||||
|
||||
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(8) // concurrency cap
|
||||
|
||||
results := make([][]byte, len(urls))
|
||||
for i, u := range urls {
|
||||
g.Go(func() error {
|
||||
body, err := fetch(ctx, u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", u, err)
|
||||
}
|
||||
results[i] = body
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
```
|
||||
|
||||
- `errgroup.WithContext` cancels remaining tasks on first error.
|
||||
- `SetLimit` bounds concurrency.
|
||||
- First non-nil error is returned; others are discarded — by design.
|
||||
|
||||
See `concurrency.md` for the full pattern.
|
||||
|
||||
---
|
||||
|
||||
## Logging errors — structured, once
|
||||
|
||||
```go
|
||||
slog.ErrorContext(ctx, "save user failed",
|
||||
slog.String("user_id", string(id)),
|
||||
slog.Any("err", err), // %w chain is fully rendered
|
||||
)
|
||||
```
|
||||
|
||||
**Log once, at the outermost frame.** Logging at every wrap site produces five log lines for one error.
|
||||
|
||||
The `sloglint` linter enforces `slog.Any("err", err)` over `slog.String("err", err.Error())` — the former preserves the chain when handlers walk the value.
|
||||
|
||||
---
|
||||
|
||||
## Antipatterns
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `_ = err` | Silent ignore | Handle, log, or wrap |
|
||||
| `if err != nil { return err }` chained 10 deep without wrap | No path info | Add one fact per layer: `fmt.Errorf("step: %w", err)` |
|
||||
| `panic(err)` in HTTP handlers | Loses error chain, hits gin Recovery | `httperr.Write(c, err)` |
|
||||
| `err.Error() == "some string"` | Brittle, breaks on wrap | Define a sentinel, use `errors.Is` |
|
||||
| `if err == sql.ErrNoRows` | Breaks under wrap | `errors.Is(err, sql.ErrNoRows)` |
|
||||
| `catch-all log.Fatal(err)` in library code | Crashes the caller's process | Return error, let main decide |
|
||||
| Returning a typed nil pointer wrapped in error interface | Classic "nil != nil" bug | Return explicit `nil` for the error |
|
||||
|
||||
The last bug deserves its own example:
|
||||
|
||||
```go
|
||||
// BUG — returns a non-nil error interface containing a nil concrete type
|
||||
func bad() error {
|
||||
var e *MyError = nil
|
||||
return e // interface wraps nil pointer; errors == nil is FALSE
|
||||
}
|
||||
|
||||
// Caller
|
||||
if err := bad(); err != nil {
|
||||
// ← entered, but err.(*MyError) is nil — surprise panic
|
||||
}
|
||||
```
|
||||
|
||||
Fix: return explicit `nil`, not a typed nil. The `nilnil` linter catches this in `(T, error)` returns.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Go blog "Working with Errors in Go 1.13+": https://go.dev/blog/go1.13-errors
|
||||
- `errors.Join` (Go 1.20+): https://pkg.go.dev/errors#Join
|
||||
- errorlint: https://github.com/polyfloyd/go-errorlint
|
||||
- nilaway nil-interface check: https://github.com/uber-go/nilaway
|
||||
@@ -0,0 +1,236 @@
|
||||
# Strict `.golangci.yml` (golangci-lint v2)
|
||||
|
||||
The single source of truth for "is this Go code acceptable". Drop this in unmodified. **Every linter below is enabled deliberately — read the rationale before disabling one.**
|
||||
|
||||
`golangci-lint` v2 changed config schema (top-level `version: "2"`). All v1 configs are incompatible. The block below is v2.
|
||||
|
||||
## `.golangci.yml`
|
||||
|
||||
```yaml
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# ── Correctness — bug catchers ───────────────────────────────
|
||||
- govet # stdlib vet, includes shadow, fieldalignment, nilness
|
||||
- staticcheck # SA1*-SA9* — the de facto Go correctness linter
|
||||
- errcheck # unhandled errors. ZERO tolerance.
|
||||
- errorlint # %w wrapping, errors.As vs type-assertion, errors.Is vs ==
|
||||
- nilerr # `return nil` after `err != nil` — classic bug
|
||||
- nilnil # returning `(nil, nil)` from a (*T, error) function
|
||||
- bodyclose # http.Response.Body not closed
|
||||
- rowserrcheck # sql.Rows.Err() not checked
|
||||
- sqlclosecheck # sql.Rows / sql.Stmt not closed
|
||||
- contextcheck # functions taking context.Context don't get context.Background()
|
||||
- fatcontext # context.WithValue() in a loop — leaks
|
||||
- copyloopvar # Go 1.22 loop-var capture — should now use the new semantics
|
||||
- intrange # use `for i := range N` (Go 1.22+) instead of `for i := 0; i < N; i++`
|
||||
- usetesting # use t.TempDir/t.Setenv over os.* in tests
|
||||
- testifylint # require vs assert correctness, ObjectsAreEqual misuse
|
||||
|
||||
# ── Style / readability — kept narrow to avoid bikeshedding ─
|
||||
- gofumpt # stricter gofmt
|
||||
- goimports # import grouping + local prefix
|
||||
- whitespace # leading/trailing whitespace
|
||||
- misspell # typos in comments and strings
|
||||
- unconvert # redundant type conversions
|
||||
- unparam # unused function parameters
|
||||
- ineffassign # ineffective assignments
|
||||
- dupword # duplicate words ("the the")
|
||||
|
||||
# ── Architecture — file size, complexity, dead code ─────────
|
||||
- gocognit # cognitive complexity per function (threshold 25)
|
||||
- gocyclo # cyclomatic complexity per function (threshold 15)
|
||||
- funlen # function length (90 lines, 60 statements)
|
||||
- lll # line length 120
|
||||
- nestif # excessive nesting depth (>4)
|
||||
- dupl # duplicate code blocks
|
||||
- revive # extensible replacement for golint; selected rules below
|
||||
- unused # unused vars/funcs/types
|
||||
|
||||
# ── Exhaustiveness — Go's weakest spot ──────────────────────
|
||||
- exhaustive # type switch and enum-like const groups completeness
|
||||
|
||||
# ── Security ────────────────────────────────────────────────
|
||||
- gosec # CWE-aware security scanner
|
||||
|
||||
# ── Logging ─────────────────────────────────────────────────
|
||||
- sloglint # slog attr style + no slog.Any(); enforce structured logs
|
||||
|
||||
# ── Performance ─────────────────────────────────────────────
|
||||
- perfsprint # fmt.Sprintf where strconv suffices
|
||||
- prealloc # slice prealloc when length is known
|
||||
- makezero # make([]T, n) with non-zero n then append (the classic bug)
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true # `_ = err` is a violation
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
settings:
|
||||
shadow:
|
||||
strict: true
|
||||
fieldalignment:
|
||||
# On by default; this catches struct layouts wasting memory.
|
||||
# Disable per-file with //nolint:fieldalignment ONLY for boundary types
|
||||
# whose JSON tag order matters for OpenAPI doc stability.
|
||||
|
||||
errorlint:
|
||||
errorf: true # %w mandatory for wrapping
|
||||
asserts: true # errors.As over type-assertion on `error`
|
||||
comparison: true # errors.Is over ==
|
||||
|
||||
gocognit:
|
||||
min-complexity: 25
|
||||
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
funlen:
|
||||
lines: 90
|
||||
statements: 60
|
||||
ignore-comments: true
|
||||
|
||||
lll:
|
||||
line-length: 120
|
||||
tab-width: 4
|
||||
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: false
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # handled by errcheck/errorlint
|
||||
- G304 # file path provided as input — too noisy for CLIs
|
||||
|
||||
sloglint:
|
||||
no-mixed-args: true # all attr or all key-value, never mixed
|
||||
kv-only: false
|
||||
attr-only: true # force slog.String(...) form
|
||||
no-global: all # disallow slog.Info; force a logger receiver
|
||||
context: scope # require *Context variants where ctx is in scope
|
||||
static-msg: true # msg must be a string literal (not fmt.Sprintf)
|
||||
no-raw-keys: true # use slog.String("key", ...) not raw "key", "val"
|
||||
key-naming-case: snake
|
||||
|
||||
testifylint:
|
||||
enable-all: true
|
||||
disable:
|
||||
- require-error # We DO use assert.Error in table-driven loops
|
||||
|
||||
revive:
|
||||
severity: warning
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: package-comments
|
||||
- name: exported
|
||||
- name: error-return
|
||||
- name: error-naming
|
||||
- name: errorf # use fmt.Errorf instead of errors.New(fmt.Sprintf)
|
||||
- name: if-return
|
||||
- name: indent-error-flow
|
||||
- name: range-val-in-closure
|
||||
- name: redefines-builtin-id
|
||||
- name: superfluous-else
|
||||
- name: unhandled-error
|
||||
arguments:
|
||||
- "fmt.Print.*"
|
||||
- "fmt.Fprint.*"
|
||||
|
||||
perfsprint:
|
||||
integer-format: true
|
||||
error-format: true
|
||||
bool-format: true
|
||||
string-format: true
|
||||
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/your-org
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
# Tests get a longer leash on funlen + lll
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- funlen
|
||||
- lll
|
||||
- dupl
|
||||
- gosec
|
||||
# Generated code never lints
|
||||
- path: \.pb\.go$
|
||||
linters: [all]
|
||||
- path: \.connect\.go$
|
||||
linters: [all]
|
||||
- path: ^.*sqlc/.*\.sql\.go$
|
||||
linters: [all]
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
```
|
||||
|
||||
## Per-linter rationale (why each is on)
|
||||
|
||||
| Linter | What it catches | Why no compromise |
|
||||
|---|---|---|
|
||||
| `errcheck` (incl. `check-blank: true`) | `_ = err`, ignored errors from `Close()`, `Write()`, `json.Marshal()` | Silent error ignore is the #1 Go bug class. Banning `_ = err` forces a decision at every site. |
|
||||
| `errorlint` | `err == io.EOF` instead of `errors.Is(err, io.EOF)`; missing `%w` in `fmt.Errorf` | Once you wrap in middleware, `==` checks silently break. `errors.Is/As` is the only safe form. |
|
||||
| `nilerr` / `nilnil` | `return nil` after `err != nil`; `return nil, nil` from `(*T, error)` | Classic AI-generated bugs. Linter catches them mechanically. |
|
||||
| `bodyclose` | `defer resp.Body.Close()` missed | Single most common Go memory leak. |
|
||||
| `contextcheck` | `ctx := context.Background()` inside a function that received `ctx` | Breaks cancellation propagation — the entire reason ctx exists. |
|
||||
| `exhaustive` | `switch x.(type)` missing a sealed-interface variant | **Go's weakest type-system spot.** This linter is the closest thing to compiler-enforced exhaustiveness. |
|
||||
| `sloglint` | `slog.Info(...)` (global), mixed `Any`/typed attrs | Without this, structured logging silently degrades into string concatenation. |
|
||||
| `govet/shadow` strict | `err := ... ; if ... { err := ...; ... }` shadowing | Hides the real error from outer scope — extremely common. |
|
||||
| `govet/fieldalignment` | Struct field order wasting memory | Cheap correctness signal. Disable per-file when JSON tag order matters for OpenAPI. |
|
||||
| `copyloopvar` + `intrange` | Pre-1.22 loop-var capture and old `for i := 0; i < N; i++` | The language modernized; the lint enforces it. |
|
||||
| `usetesting` | `os.Setenv` / `os.Mkdir` in tests instead of `t.Setenv` / `t.TempDir` | Avoids test isolation bugs. |
|
||||
| `gocognit` / `gocyclo` / `funlen` | Functions exceeding cognitive thresholds | Direct architectural signal — same purpose as the 250 LOC ceiling, at function granularity. |
|
||||
| `gosec` | CWE patterns — SQL injection, weak crypto, path traversal | Production must pass this. |
|
||||
| `testifylint` | `assert.Equal` where `require.Equal` was meant; `ObjectsAreEqual` misuse | Subtle test-correctness bugs. |
|
||||
| `perfsprint` | `fmt.Sprintf("%d", n)` instead of `strconv.Itoa(n)` | 5–10x faster in tight loops, lints catch the lazy form. |
|
||||
|
||||
## `nolint` policy
|
||||
|
||||
`//nolint:linter1,linter2 // <reason>` is permitted with **two hard rules**:
|
||||
|
||||
1. **One linter at a time per directive.** No `//nolint:all`. No omitting the linter name.
|
||||
2. **A reason after `//` is mandatory.** "Generated code", "false positive — protobuf imports", "OpenAPI field order" are acceptable. "Ignore" is not.
|
||||
|
||||
The skill auto-rejects `//nolint` without a reason. So does `revive` if you enable its `nolint` rule.
|
||||
|
||||
## CI gate
|
||||
|
||||
```bash
|
||||
gofumpt -l . | (! grep .) # format
|
||||
golangci-lint run --timeout 5m ./... # everything above
|
||||
go vet -vettool=$(which fieldalignment) ./... # extra check (also in govet)
|
||||
nilaway ./... # nil-deref static analysis
|
||||
go test -race -shuffle=on -count=1 ./... # races + ordering
|
||||
```
|
||||
|
||||
Any non-zero exit = the change does not ship.
|
||||
|
||||
## Sources
|
||||
|
||||
- golangci-lint v2 docs: https://golangci-lint.run/docs/configuration/
|
||||
- staticcheck rules: https://staticcheck.dev/docs/checks
|
||||
- sloglint: https://github.com/go-simpler/sloglint
|
||||
- exhaustive: https://github.com/nishanths/exhaustive
|
||||
- nilaway: https://github.com/uber-go/nilaway
|
||||
@@ -0,0 +1,375 @@
|
||||
# RPC — Connect-Go (default) + grpc-go (fallback) + protovalidate
|
||||
|
||||
`connectrpc/connect-go` is the default. It is wire-compatible with gRPC, also speaks Connect protocol + gRPC-Web from browsers, and uses ordinary `net/http` so middleware (logging, auth, tracing) composes the same way as REST. Reach for raw `grpc-go` only when you need a gRPC-specific feature Connect lacks.
|
||||
|
||||
---
|
||||
|
||||
## When Connect vs grpc-go
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Standard unary + server-streaming + client-streaming | **Connect** |
|
||||
| Browser client without `grpc-web` proxy | **Connect** (native gRPC-Web support) |
|
||||
| HTTP/1.1 fallback for hostile networks | **Connect** (gRPC requires HTTP/2 end-to-end) |
|
||||
| Server reflection for `grpcurl` | grpc-go (Connect has reflection too, but ecosystem smaller) |
|
||||
| Bidirectional streaming with frame-level control | grpc-go |
|
||||
| Strict gRPC environment (Envoy with gRPC filters, Istio strict mode) | grpc-go |
|
||||
|
||||
**Default**: Connect. The default has been correct since 2024.
|
||||
|
||||
---
|
||||
|
||||
## Toolchain — Buf, not protoc
|
||||
|
||||
```bash
|
||||
go install github.com/bufbuild/buf/cmd/buf@latest
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
|
||||
go install github.com/bufbuild/protovalidate/cmd/protoc-gen-go-vtproto@latest
|
||||
```
|
||||
|
||||
Buf replaces `protoc` for everything: linting, breaking-change detection, codegen, formatting. The `protoc` toolchain is dead-letter walking — every modern proto project uses Buf.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
proto/
|
||||
buf.yaml
|
||||
buf.gen.yaml
|
||||
buf.lock
|
||||
myservice/v1/
|
||||
user.proto
|
||||
auth.proto
|
||||
|
||||
gen/
|
||||
myservice/v1/
|
||||
user.pb.go # protoc-gen-go output
|
||||
auth.pb.go
|
||||
myservicev1connect/ # protoc-gen-connect-go output
|
||||
user.connect.go
|
||||
auth.connect.go
|
||||
```
|
||||
|
||||
**`gen/` is committed.** Generated code is part of the API contract; CI proves it is up-to-date.
|
||||
|
||||
---
|
||||
|
||||
## `buf.yaml`
|
||||
|
||||
```yaml
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
```
|
||||
|
||||
## `buf.gen.yaml`
|
||||
|
||||
```yaml
|
||||
version: v2
|
||||
managed:
|
||||
enabled: true
|
||||
override:
|
||||
- file_option: go_package_prefix
|
||||
value: github.com/your-org/myservice/gen
|
||||
plugins:
|
||||
- remote: buf.build/protocolbuffers/go
|
||||
out: gen
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- remote: buf.build/connectrpc/go
|
||||
out: gen
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- remote: buf.build/bufbuild/validate-go
|
||||
out: gen
|
||||
opt:
|
||||
- paths=source_relative
|
||||
```
|
||||
|
||||
The `buf.build/...` plugin URIs use Buf's hosted remote registry — no local plugin installation needed.
|
||||
|
||||
## Taskfile target
|
||||
|
||||
```yaml
|
||||
gen:proto:
|
||||
cmds:
|
||||
- buf lint
|
||||
- buf format -w
|
||||
- buf generate
|
||||
sources:
|
||||
- proto/**/*.proto
|
||||
- buf.yaml
|
||||
- buf.gen.yaml
|
||||
```
|
||||
|
||||
Run `task gen:proto` after editing any `.proto`. CI runs `buf generate` then `git diff --exit-code` to catch stale generated code.
|
||||
|
||||
---
|
||||
|
||||
## A `.proto` with validation
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
package myservice.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
|
||||
option go_package = "github.com/your-org/myservice/gen/myservice/v1;myservicev1";
|
||||
|
||||
service UserService {
|
||||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc StreamEvents(StreamEventsRequest) returns (stream Event);
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
string email = 1 [(buf.validate.field).string.email = true];
|
||||
string username = 2 [
|
||||
(buf.validate.field).string.min_len = 3,
|
||||
(buf.validate.field).string.max_len = 32,
|
||||
(buf.validate.field).string.pattern = "^[a-zA-Z0-9_]+$"
|
||||
];
|
||||
int32 age = 3 [
|
||||
(buf.validate.field).int32.gte = 13,
|
||||
(buf.validate.field).int32.lte = 130
|
||||
];
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message User {
|
||||
string id = 1;
|
||||
string email = 2;
|
||||
string username = 3;
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
}
|
||||
```
|
||||
|
||||
`protovalidate` replaces the abandoned `protoc-gen-validate` — it is the official Buf-backed successor as of 2024, supported by Connect's interceptor pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Server — Connect
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"buf.build/go/protovalidate"
|
||||
validateinterceptor "connectrpc.com/validate"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
myservicev1 "github.com/your-org/myservice/gen/myservice/v1"
|
||||
"github.com/your-org/myservice/gen/myservice/v1/myservicev1connect"
|
||||
)
|
||||
|
||||
type UserServer struct {
|
||||
svc *UserService
|
||||
}
|
||||
|
||||
func (s *UserServer) CreateUser(
|
||||
ctx context.Context,
|
||||
req *connect.Request[myservicev1.CreateUserRequest],
|
||||
) (*connect.Response[myservicev1.CreateUserResponse], error) {
|
||||
|
||||
// protovalidate already ran via the interceptor below.
|
||||
// req.Msg is guaranteed to satisfy the .proto constraints.
|
||||
|
||||
user, err := s.svc.Create(ctx, req.Msg.Email, req.Msg.Username, req.Msg.Age)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return connect.NewResponse(&myservicev1.CreateUserResponse{
|
||||
User: userToProto(user),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
validator, _ := protovalidate.New()
|
||||
interceptors := connect.WithInterceptors(
|
||||
loggingInterceptor(),
|
||||
validateinterceptor.NewInterceptor(validator),
|
||||
)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(myservicev1connect.NewUserServiceHandler(
|
||||
&UserServer{svc: newUserService()},
|
||||
interceptors,
|
||||
))
|
||||
|
||||
// h2c lets the server speak HTTP/2 cleartext for gRPC clients.
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: h2c.NewHandler(mux, &http2.Server{}),
|
||||
}
|
||||
slog.Info("rpc server listening", slog.String("addr", srv.Addr))
|
||||
if err := srv.ListenAndServe(); err != nil { slog.Error("rpc", slog.Any("err", err)) }
|
||||
}
|
||||
```
|
||||
|
||||
The handler is **just an `http.Handler`** — mount it in the same `http.ServeMux` as your REST routes if you want one binary serving both.
|
||||
|
||||
---
|
||||
|
||||
## Error mapping — Connect codes
|
||||
|
||||
```go
|
||||
func mapError(err error) error {
|
||||
if err == nil { return nil }
|
||||
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidEmail),
|
||||
errors.Is(err, domain.ErrInvalidUsername):
|
||||
return connect.NewError(connect.CodeInvalidArgument, err)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return connect.NewError(connect.CodeNotFound, err)
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
return connect.NewError(connect.CodeUnauthenticated, err)
|
||||
case errors.Is(err, ErrConflict):
|
||||
return connect.NewError(connect.CodeAlreadyExists, err)
|
||||
default:
|
||||
slog.Error("unmapped rpc error", slog.Any("err", err))
|
||||
return connect.NewError(connect.CodeInternal, errors.New("internal"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Connect codes map 1:1 to gRPC codes. Clients see canonical error semantics.
|
||||
|
||||
---
|
||||
|
||||
## Logging interceptor
|
||||
|
||||
```go
|
||||
func loggingInterceptor() connect.UnaryInterceptorFunc {
|
||||
return func(next connect.UnaryFunc) connect.UnaryFunc {
|
||||
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
||||
start := time.Now()
|
||||
res, err := next(ctx, req)
|
||||
attrs := []slog.Attr{
|
||||
slog.String("proc", req.Spec().Procedure),
|
||||
slog.Duration("elapsed", time.Since(start)),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.Any("err", err))
|
||||
slog.LogAttrs(ctx, slog.LevelWarn, "rpc failed", attrs...)
|
||||
} else {
|
||||
slog.LogAttrs(ctx, slog.LevelInfo, "rpc ok", attrs...)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For streaming, implement the full `connect.Interceptor` (`WrapStreamingClient`, `WrapStreamingHandler`). Pattern is identical.
|
||||
|
||||
---
|
||||
|
||||
## Server streaming
|
||||
|
||||
```go
|
||||
func (s *UserServer) StreamEvents(
|
||||
ctx context.Context,
|
||||
req *connect.Request[myservicev1.StreamEventsRequest],
|
||||
stream *connect.ServerStream[myservicev1.Event],
|
||||
) error {
|
||||
events, errs := s.svc.Subscribe(ctx, req.Msg.UserId)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case e, ok := <-events:
|
||||
if !ok { return nil }
|
||||
if err := stream.Send(eventToProto(e)); err != nil {
|
||||
return err
|
||||
}
|
||||
case err := <-errs:
|
||||
return connect.NewError(connect.CodeInternal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Same shape as SSE in `backend-stack.md`. Connect handles HTTP/2 framing.
|
||||
|
||||
---
|
||||
|
||||
## Client
|
||||
|
||||
```go
|
||||
client := myservicev1connect.NewUserServiceClient(
|
||||
http.DefaultClient,
|
||||
"https://api.example.com",
|
||||
// Use connect.WithGRPC() if the server is grpc-go and you want strict gRPC framing.
|
||||
// Default is Connect protocol — works with Connect or gRPC servers transparently.
|
||||
)
|
||||
|
||||
res, err := client.CreateUser(ctx, connect.NewRequest(&myservicev1.CreateUserRequest{
|
||||
Email: "a@b.com",
|
||||
Username: "alice",
|
||||
Age: 30,
|
||||
}))
|
||||
if err != nil {
|
||||
var connectErr *connect.Error
|
||||
if errors.As(err, &connectErr) {
|
||||
slog.Error("rpc failed",
|
||||
slog.String("code", connectErr.Code().String()),
|
||||
slog.String("msg", connectErr.Message()))
|
||||
}
|
||||
return err
|
||||
}
|
||||
slog.Info("created", slog.String("id", res.Msg.User.Id))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When you genuinely need raw grpc-go
|
||||
|
||||
```go
|
||||
import "google.golang.org/grpc"
|
||||
|
||||
lis, _ := net.Listen("tcp", ":8080")
|
||||
srv := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(loggingUnaryInterceptor),
|
||||
)
|
||||
myservicev1.RegisterUserServiceServer(srv, &userServer{})
|
||||
_ = srv.Serve(lis)
|
||||
```
|
||||
|
||||
The codegen is from `protoc-gen-go-grpc` (different binary from `protoc-gen-connect-go`). You can codegen **both** in the same `buf.gen.yaml` and switch by importing the right package. Most teams pick one.
|
||||
|
||||
---
|
||||
|
||||
## When NOT to use RPC at all
|
||||
|
||||
If your callers are all browsers, mobile apps, third-party developers, or the long tail of "things humans curl": **stay with REST + OpenAPI**. RPC's overhead is justified for service-to-service inside a single org. Outside that boundary, JSON over HTTP wins on debuggability.
|
||||
|
||||
`oapi-codegen/oapi-codegen/v2` generates Go server stubs and clients from OpenAPI 3 — the REST equivalent of what Connect does for proto. Same parse-don't-validate boundary discipline, different wire format.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Connect docs: https://connectrpc.com/docs/go/getting-started
|
||||
- Buf: https://buf.build/docs
|
||||
- protovalidate: https://github.com/bufbuild/protovalidate
|
||||
- "Why we replaced protoc with buf" (Buf blog): https://buf.build/blog
|
||||
- gRPC vs Connect comparison: https://connectrpc.com/docs/introduction
|
||||
@@ -0,0 +1,337 @@
|
||||
# Library Defaults — Full Decision Tree (Go 2026)
|
||||
|
||||
The opinionated, in-production stack for 2026 Go. Every entry has a one-line rationale and a canonical snippet so the agent does not relearn each library's idioms.
|
||||
|
||||
The biggest difference from Python/Rust/TypeScript: **Go has fewer "best" choices and more "boring" choices.** The standard library is the default; reach outside it only when the rationale below applies.
|
||||
|
||||
---
|
||||
|
||||
## HTTP framework — `gin` (default) or `chi` (minimalist) or `net/http` (no deps)
|
||||
|
||||
The reality of 2026 Go: **`gin` runs ~48% of new Go API projects** (Go Developer Survey 2024 + crawls of new repos), with `gorilla/mux` (~17%, in maintenance), `echo` (~16%), and `fiber` (~11%) the remaining quarter. The skill picks gin not because it is technically superior — it is not — but because:
|
||||
|
||||
1. The ecosystem (middleware, examples, SO answers) is largest.
|
||||
2. The CLIProxyAPI codebase, which this skill's `backend-stack.md` is distilled from, uses gin in production for OpenAI/Gemini/Claude proxying including SSE streaming and WebSocket upgrades. That is real reference code, not a toy.
|
||||
3. Gin's `Context` API is the closest thing Go has to a framework-blessed "request-scoped object", which makes middleware composition straightforward.
|
||||
|
||||
```go
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func main() {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), middleware.RequestLogger(), middleware.RequestID())
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
_ = r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
**Pick `chi` instead** when:
|
||||
- You want `net/http`-compatible handlers (you do, eventually — chi is closer to stdlib).
|
||||
- The service is small and you do not need gin's binding helpers.
|
||||
|
||||
**Pick `net/http` (stdlib) directly** when:
|
||||
- The service has fewer than 10 routes and zero auth complexity. Go 1.22's enhanced `ServeMux` (method+path patterns) eliminated 80% of the historical reason to use a framework.
|
||||
|
||||
**Never use** `gorilla/mux` (effectively in maintenance), `fiber` (uses `fasthttp` which is **not stdlib-compatible**, so middleware ecosystem is split), or `echo` (smaller eco than gin, no real advantage today).
|
||||
|
||||
See `backend-stack.md` for the gin canonical layout, middleware ordering, SSE, graceful shutdown, structured logging integration.
|
||||
|
||||
---
|
||||
|
||||
## RPC — `connectrpc/connect-go`
|
||||
|
||||
The default RPC layer. **Use Connect, not raw grpc-go**, unless you have a measured reason.
|
||||
|
||||
- Connect is wire-compatible with gRPC AND speaks HTTP/1.1 + HTTP/2 + Connect protocol. One server, three clients (gRPC, gRPC-Web, Connect-Web from browsers).
|
||||
- No `grpcurl` needed for debugging — `curl -H "Content-Type: application/json" -d ...` works.
|
||||
- Streaming, interceptors, deadlines, errors are first-class.
|
||||
- Buf toolchain (`buf generate`, `buf lint`, `buf breaking`) for codegen is dramatically nicer than `protoc`.
|
||||
|
||||
```go
|
||||
// Server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(elizav1connect.NewElizaServiceHandler(&elizaServer{}))
|
||||
_ = http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))
|
||||
|
||||
// Client
|
||||
client := elizav1connect.NewElizaServiceClient(
|
||||
http.DefaultClient,
|
||||
"http://localhost:8080",
|
||||
)
|
||||
res, err := client.Say(ctx, connect.NewRequest(&elizav1.SayRequest{Sentence: "hi"}))
|
||||
```
|
||||
|
||||
**Use raw `grpc-go`** only when:
|
||||
- You need server-streaming-from-multiple-services with a single gRPC mux.
|
||||
- You are integrating with a strict gRPC-only environment (Envoy proxy with gRPC reflection, Istio strict-gRPC).
|
||||
|
||||
See `grpc-connect.md`.
|
||||
|
||||
---
|
||||
|
||||
## Database — `pgx/v5` + `sqlc` + `goose`
|
||||
|
||||
```bash
|
||||
go get github.com/jackc/pgx/v5
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
```
|
||||
|
||||
- **`pgx/v5`** is faster, more type-safe, and has better PostgreSQL feature coverage than `database/sql + lib/pq`. Use the `pgxpool` package for connection pooling. Avoid `database/sql` driver mode — it loses pgx's batch, COPY, listen/notify.
|
||||
- **`sqlc`** generates type-safe Go from `.sql` files. Hand-written SQL with hand-written struct mapping is the #1 source of subtle DB bugs. sqlc eliminates the class.
|
||||
- **`goose`** for migrations — small, command-line first, no global state.
|
||||
|
||||
**Never use** `gorm` (active record, slow, brings runtime reflection into hot paths, encourages N+1 queries). **Never use** `ent` (heavy, opinionated graph layer) unless you specifically want a graph-shaped data model.
|
||||
|
||||
See `sqlc-pgx.md`.
|
||||
|
||||
---
|
||||
|
||||
## Validation — three layers, three tools
|
||||
|
||||
Go has no Pydantic / Zod equivalent and **does not need one** — but only because you wire three layers properly:
|
||||
|
||||
| Layer | Tool | Pattern |
|
||||
|---|---|---|
|
||||
| HTTP boundary (gin/chi/net/http) | `go-playground/validator/v10` via struct tags | `binding:"required,email,min=3"` |
|
||||
| RPC boundary (protobuf) | `bufbuild/protovalidate-go` | `(buf.validate.field).string.min_len = 3` in `.proto` |
|
||||
| Domain core | **Smart constructor + unexported fields** | `NewEmail(s) (Email, error)` returns a type whose fields cannot be set from outside |
|
||||
|
||||
```go
|
||||
// HTTP boundary
|
||||
type CreateUserReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
|
||||
}
|
||||
|
||||
// Domain — once a value is of type Email it is provably valid
|
||||
type Email struct{ raw string }
|
||||
func NewEmail(s string) (Email, error) {
|
||||
if !emailRegex.MatchString(s) { return Email{}, ErrInvalidEmail }
|
||||
return Email{raw: strings.ToLower(s)}, nil
|
||||
}
|
||||
func (e Email) String() string { return e.raw }
|
||||
```
|
||||
|
||||
The boundary parses raw input into the domain type **once**. Inside the domain, no further validation is permitted — the types prove it. This is parse-don't-validate adapted to Go.
|
||||
|
||||
See `data-modeling.md` for the full pattern.
|
||||
|
||||
---
|
||||
|
||||
## Logging — `log/slog` (stdlib)
|
||||
|
||||
```go
|
||||
import "log/slog"
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
AddSource: true,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.InfoContext(ctx, "request handled",
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", 200),
|
||||
slog.Duration("elapsed", elapsed),
|
||||
)
|
||||
```
|
||||
|
||||
- **stdlib since 1.21**, stable since 1.23. Performance is on par with zerolog for structured output, and faster than logrus by a wide margin.
|
||||
- The `slog.Handler` interface is implemented by all major exporters (OpenTelemetry, Datadog, Honeycomb).
|
||||
- The skill bans `logrus`, `zap`, `zerolog` for new code. They are not bad — they are simply superseded. Existing projects on those keep them; new files use slog.
|
||||
|
||||
Use the `sloglint` linter from `golangci-strict.md` to enforce attr style (`slog.String(...)` instead of `slog.Any(...)`).
|
||||
|
||||
---
|
||||
|
||||
## CLI — `cobra` + `pflag` + slog
|
||||
|
||||
```bash
|
||||
go install github.com/spf13/cobra-cli@latest
|
||||
cobra-cli init mytool
|
||||
cobra-cli add server
|
||||
```
|
||||
|
||||
`cobra` is the de facto Go CLI framework — Kubernetes, Docker CLI, Helm, GitHub CLI all use it. The companion `viper` for config-file-+-env-+-flag merging is **optional**: prefer `caarlos0/env/v11` for env-only configs (12-factor apps), reach for viper only when you genuinely need file-based config.
|
||||
|
||||
See `cobra-stack.md`.
|
||||
|
||||
---
|
||||
|
||||
## TUI — `bubbletea v2` + `bubbles v2` + `lipgloss v2`
|
||||
|
||||
Use **v2 RC** (`charm.land/bubbletea/v2`), not v1. The v2 model adds:
|
||||
|
||||
- `tea.View{Cursor: *tea.Cursor, ...}` for real-cursor positioning.
|
||||
- `SetVirtualCursor(false)` on textareas — lets the terminal own the cursor, which is **required** for CJK IME (Korean Hangul composition, Japanese kana→kanji conversion, Chinese pinyin lookup).
|
||||
- Granular mouse events (`MouseClickMsg`, `MouseMotionMsg`, `MouseReleaseMsg`) instead of v1's coarse `MouseMsg`.
|
||||
|
||||
This is not a preference. v1 has no way to position the IME candidate window correctly — Korean input shows up two cells to the left of where you typed, every time. **If your TUI accepts text input AND your users include CJK speakers, v1 is broken.**
|
||||
|
||||
See `bubbletea-v2.md` for the full IME-correct skeleton.
|
||||
|
||||
---
|
||||
|
||||
## HTTP client — stdlib + `hashicorp/go-retryablehttp`
|
||||
|
||||
Default: `net/http.Client` with a tuned `http.Transport`. The stdlib client is **already excellent** in 2026 — HTTP/2 by default, connection pooling, sane timeouts when configured.
|
||||
|
||||
```go
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 40,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For retry/backoff, add `github.com/hashicorp/go-retryablehttp` — small, single-purpose, integrates as a wrapper.
|
||||
|
||||
**Never use** `resty` (too much magic, hides headers, encourages wrong defaults). `req` is fine but adds dependency surface for marginal benefit over the stdlib + retry wrapper.
|
||||
|
||||
---
|
||||
|
||||
## JSON — stdlib (default), `goccy/go-json` (perf), `bytedance/sonic` (extreme perf)
|
||||
|
||||
Stdlib `encoding/json` improved dramatically in Go 1.21+. **Use it.**
|
||||
|
||||
Reach for `goccy/go-json` (~3x faster) only when you have measured a hot-path bottleneck:
|
||||
|
||||
```go
|
||||
import json "github.com/goccy/go-json"
|
||||
// drop-in replacement — same API
|
||||
```
|
||||
|
||||
Reach for `bytedance/sonic` (~5x faster, requires amd64/arm64) for production proxies with thousands of RPS of JSON traversal. CLIProxyAPI uses `tidwall/gjson` + `tidwall/sjson` for **partial-tree mutation without full unmarshal** — a different optimization, useful when you transform large payloads. See `backend-stack.md`.
|
||||
|
||||
---
|
||||
|
||||
## Concurrency primitives — stdlib only
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Goroutine group with error propagation | `golang.org/x/sync/errgroup` |
|
||||
| Semaphore | `golang.org/x/sync/semaphore` |
|
||||
| Single-flight dedup | `golang.org/x/sync/singleflight` |
|
||||
| Lazy init | **`sync.OnceValue` / `sync.OnceFunc`** (Go 1.21+, replaces `sync.Once` for typed values) |
|
||||
| Atomic counter | `atomic.Int64` (Go 1.19+, typed atomics — don't use the old func-style) |
|
||||
| Channel-based fanout | `chan T` with `errgroup` for shutdown |
|
||||
|
||||
The `x/sync` packages are stdlib-quality but live outside `std`. See `concurrency.md` for the discipline.
|
||||
|
||||
---
|
||||
|
||||
## Time — stdlib + `benbjohnson/clock` for tests
|
||||
|
||||
```go
|
||||
type Clock interface { Now() time.Time }
|
||||
// Production
|
||||
var realClock Clock = clockImpl{}
|
||||
// Test
|
||||
fake := clock.NewMock()
|
||||
fake.Set(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
```
|
||||
|
||||
**Never call `time.Now()` directly inside domain code.** Inject a `Clock`. Tests become deterministic, no `time.Sleep` flakiness.
|
||||
|
||||
---
|
||||
|
||||
## IDs — `google/uuid` (UUID v4/v7) or `xid` (sortable short ID)
|
||||
|
||||
```go
|
||||
import "github.com/google/uuid"
|
||||
id := uuid.Must(uuid.NewV7()) // sortable, time-ordered, 128-bit
|
||||
```
|
||||
|
||||
UUID v7 is the modern default — sortable like v6, random like v4. Use v4 only when leaking creation time is a privacy concern.
|
||||
|
||||
For short, URL-safe IDs (~12 bytes, sortable) use `rs/xid` — Kubernetes-style.
|
||||
|
||||
---
|
||||
|
||||
## Crypto — stdlib + `alecthomas/argon2id` for passwords
|
||||
|
||||
Stdlib `crypto/*` for everything. For password hashing, **argon2id is the 2026 standard** — bcrypt is acceptable but argon2 is OWASP's recommendation since 2023.
|
||||
|
||||
```go
|
||||
import "github.com/alecthomas/argon2id"
|
||||
hash, err := argon2id.CreateHash("password", argon2id.DefaultParams)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data — `apache/arrow-go/v18` + `marcboeker/go-duckdb` + `gonum`
|
||||
|
||||
Same philosophy as Python's "never pandas":
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Tabular over CSV/Parquet/JSON | DuckDB-Go bindings — zero-copy Arrow integration |
|
||||
| In-memory frame | Arrow + custom code (Go has no pandas-equivalent and that's fine) |
|
||||
| Numerical | `gonum.org/v1/gonum` |
|
||||
| Stats | `gonum/stat` |
|
||||
|
||||
Go's data-science story is intentionally thin. For heavy data work, write the pipeline in Polars/DuckDB (see `python/data-processing.md`), expose the result via Parquet or Arrow, consume from Go.
|
||||
|
||||
---
|
||||
|
||||
## Testing — stdlib + selective additions
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Assertions | `stretchr/testify/require` (fail-fast) — `assert` only in table-driven loops |
|
||||
| Snapshots / golden | `hexops/autogold/v2` (auto-updates with `-update`) |
|
||||
| Property-based | `pgregory.net/rapid` (modern) or stdlib `testing/quick` |
|
||||
| Mocks | `go.uber.org/mock` (gomock successor) |
|
||||
| HTTP mocks | `h2non/gock` for outbound, stdlib `httptest` for inbound |
|
||||
| Integration containers | `testcontainers/testcontainers-go` |
|
||||
| Goroutine leak | `go.uber.org/goleak` |
|
||||
| Benchmarks | stdlib `testing.B` + `perf.dev/benchstat` |
|
||||
|
||||
See `testing.md` for canonical patterns.
|
||||
|
||||
---
|
||||
|
||||
## Config — `caarlos0/env/v11`
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Port int `env:"PORT" envDefault:"8080"`
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil { log.Fatal(err) }
|
||||
```
|
||||
|
||||
Pure 12-factor. Defaults via struct tag, required marker, parsing for `time.Duration`, slices, maps. **Use viper only if you also need file-based config** — most services do not.
|
||||
|
||||
---
|
||||
|
||||
## Choosing an unfamiliar dependency — the checklist
|
||||
|
||||
Before `go get`-ing anything new:
|
||||
|
||||
1. Is it maintained? Latest tag within 12 months? Owner active?
|
||||
2. Does it expose stdlib-compatible types (`io.Reader`, `context.Context`, `http.Handler`)? If it invents its own `Connection` or `Request` type, that's a yellow flag.
|
||||
3. Does it use `init()` for side effects? **REJECT.** `init()` ruins testability.
|
||||
4. Does it call `log.Fatal` / `panic` outside of true programmer-error paths? **REJECT.**
|
||||
5. Does it have a `context.Context` first-arg convention? If not, **REJECT** — cancellation is non-negotiable.
|
||||
6. Does adding it overlap with something already in your `go.mod`? Pick one.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- 2024 Go Developer Survey: https://go.dev/blog/survey2024-h1-results
|
||||
- Connect-Go docs: https://connectrpc.com/docs/go/getting-started
|
||||
- sqlc: https://docs.sqlc.dev
|
||||
- bubbletea v2 IME: https://github.com/code-yeongyu/bubbletea-wm (reference for `SetVirtualCursor(false)` pattern)
|
||||
- CLIProxyAPI (gin + SSE + WebSocket in production): https://github.com/router-for-me/CLIProxyAPI
|
||||
- slog blog: https://go.dev/blog/slog
|
||||
@@ -0,0 +1,202 @@
|
||||
# One-Liners and Disposable Scripts
|
||||
|
||||
Production hygiene with throwaway ergonomics. Go scripts get the same strict lints, the same type discipline, the same 250 LOC ceiling. The difference: they live as single `.go` files invoked via `go run`, not as full modules.
|
||||
|
||||
Python has PEP 723 + `uv run`. Rust has `rust-script`. **Go has `go run` directly** — no extra tooling needed.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Single-file `go run`
|
||||
|
||||
A `.go` file with a `main` package, run directly:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
// fetch.go — fetch a URL and print body length.
|
||||
//
|
||||
// Usage:
|
||||
// go run fetch.go <url>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("usage: go run fetch.go <url>")
|
||||
}
|
||||
resp, err := http.Get(os.Args[1])
|
||||
if err != nil { log.Fatal(err) }
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
fmt.Printf("%d bytes\n", len(body))
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go run fetch.go https://example.com`.
|
||||
|
||||
The `//go:build ignore` directive keeps this file out of `go build ./...` — it is a script, not part of the module. Without that line, every `.go` file in the package gets compiled into your binary.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Throwaway directory under `scripts/`
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── go.mod
|
||||
├── internal/...
|
||||
└── scripts/
|
||||
├── seed/
|
||||
│ └── main.go # `go run ./scripts/seed`
|
||||
├── migrate/
|
||||
│ └── main.go
|
||||
└── one-time-fix/
|
||||
└── main.go
|
||||
```
|
||||
|
||||
Each `scripts/<name>/main.go` is its own `main` package. Invoke as `go run ./scripts/seed/`. Dependencies are shared with the parent module — no separate `go.mod`.
|
||||
|
||||
This is the right pattern when:
|
||||
|
||||
- You need module deps (sqlc, pgx, your own internal packages).
|
||||
- You want IDE support, type-checking, test coverage.
|
||||
- The script lives alongside the project, runs in CI.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Inline `go run` from shell
|
||||
|
||||
```bash
|
||||
go run -mod=mod <(cat <<'EOF'
|
||||
package main
|
||||
import "fmt"
|
||||
func main() { fmt.Println("hello") }
|
||||
EOF
|
||||
)
|
||||
```
|
||||
|
||||
Rare, but useful for one-shot terminal experiments. The `<(...)` is process substitution; `go run -mod=mod` reads from stdin.
|
||||
|
||||
---
|
||||
|
||||
## Hard rules for scripts
|
||||
|
||||
Even a 30-line script follows the philosophy:
|
||||
|
||||
1. **Typed flags via `flag` or `pflag`**, not `os.Args` string parsing past 2 args.
|
||||
```go
|
||||
var (
|
||||
url = flag.String("url", "", "URL to fetch")
|
||||
limit = flag.Int("limit", 100, "max bytes")
|
||||
)
|
||||
flag.Parse()
|
||||
if *url == "" { log.Fatal("--url required") }
|
||||
```
|
||||
|
||||
2. **`context.Context` propagation** wherever I/O happens.
|
||||
```go
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", *url, nil)
|
||||
```
|
||||
|
||||
3. **`log.Fatal` is fine in `main()`** of a script (programmer error / fatal path), but **never inside any function the script imports.** Library code returns errors.
|
||||
|
||||
4. **Errors get wrapped.** Same rule as production code:
|
||||
```go
|
||||
if err != nil { return fmt.Errorf("fetch %s: %w", *url, err) }
|
||||
```
|
||||
|
||||
5. **Resources released via `defer`.** No "I'll fix it later".
|
||||
|
||||
6. **slog for output if it must be parseable.** `fmt.Println` for one-shot terminal output is fine.
|
||||
|
||||
7. **No more than 250 pure LOC.** If it grows, it stops being a script and becomes a subcommand of your CLI tool.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Standalone tool with deps — temporary module
|
||||
|
||||
Some scripts need deps the parent module does not have. Two options:
|
||||
|
||||
### Option A — script in its own tiny module
|
||||
|
||||
```bash
|
||||
mkdir /tmp/migrate-tool && cd $_
|
||||
go mod init scratch.local/migrate-tool
|
||||
go get github.com/pressly/goose/v3
|
||||
cat > main.go <<'EOF'
|
||||
package main
|
||||
import ... // use goose
|
||||
func main() { ... }
|
||||
EOF
|
||||
go run .
|
||||
```
|
||||
|
||||
Run, then delete `/tmp/migrate-tool`. Throwaway.
|
||||
|
||||
### Option B — `gorun` (community tool)
|
||||
|
||||
```bash
|
||||
go install github.com/erning/gorun@latest
|
||||
|
||||
cat > script.go <<'EOF'
|
||||
//usr/bin/env gorun "$0" "$@"; exit
|
||||
// /// go.mod
|
||||
// module scratch
|
||||
// go 1.23
|
||||
// require github.com/spf13/cobra v1.8.0
|
||||
// ///
|
||||
|
||||
package main
|
||||
...
|
||||
EOF
|
||||
chmod +x script.go
|
||||
./script.go
|
||||
```
|
||||
|
||||
`gorun` parses the inline `go.mod` block, materializes a temp module, runs the script. Niche tool — only if you want the executable-script experience.
|
||||
|
||||
---
|
||||
|
||||
## When a script becomes a CLI
|
||||
|
||||
If your script needs:
|
||||
|
||||
- More than one subcommand
|
||||
- Long-term storage of state
|
||||
- Help text more than a paragraph
|
||||
- Repeated invocations from CI
|
||||
|
||||
... promote it to a real CLI tool via `cobra` — see `cobra-stack.md`. The boundary is fuzzy; trust your judgment, but **a 500-line "script" is not a script.**
|
||||
|
||||
---
|
||||
|
||||
## Antipatterns
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `os.Args[1]` indexing without length check | Panics on missing arg | `flag.Parse()` with explicit checks |
|
||||
| `log.Fatal` inside a function the script imports | Crashes caller's process | Return error |
|
||||
| `panic(err)` for expected failures | Same as above | `log.Fatal` in `main`, error return elsewhere |
|
||||
| Skipping `defer resp.Body.Close()` because "it's a script" | Leaks fd | Always close |
|
||||
| One 800-LOC `main.go` "to keep it simple" | Now harder to read than a real CLI | Promote to `cmd/<name>/` with subcommands |
|
||||
| `// TODO: handle error` | Production-grade hygiene means production-grade hygiene | Handle now or document why ignored |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- `go run` docs: https://pkg.go.dev/cmd/go#hdr-Compile_and_run_Go_program
|
||||
- `//go:build` constraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints
|
||||
- `signal.NotifyContext`: https://pkg.go.dev/os/signal#NotifyContext
|
||||
- gorun: https://github.com/erning/gorun
|
||||
@@ -0,0 +1,471 @@
|
||||
# Database Stack — sqlc + pgx + goose + testcontainers
|
||||
|
||||
The canonical 2026 PostgreSQL stack. **Type-safe SQL with zero runtime reflection**, hot-path-friendly connection pooling, sane migrations, real Postgres in tests.
|
||||
|
||||
If you came here from a `gorm` project: gorm is rejected. See "Why not gorm" at the end.
|
||||
|
||||
---
|
||||
|
||||
## Toolchain
|
||||
|
||||
```bash
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
internal/store/
|
||||
├── sqlc.yaml # sqlc config
|
||||
├── schema.sql # the cumulative DDL sqlc parses
|
||||
├── queries/ # one *.sql per resource
|
||||
│ ├── users.sql
|
||||
│ ├── orders.sql
|
||||
│ └── sessions.sql
|
||||
├── sqlc/ # GENERATED — do not hand-edit
|
||||
│ ├── db.go
|
||||
│ ├── models.go
|
||||
│ ├── users.sql.go
|
||||
│ ├── orders.sql.go
|
||||
│ └── sessions.sql.go
|
||||
├── migrations/ # goose migrations, ordered
|
||||
│ ├── 20260101000001_create_users.sql
|
||||
│ └── 20260102000001_add_orders.sql
|
||||
├── pool.go # pgxpool factory
|
||||
├── user_store.go # domain-facing wrapper around sqlc
|
||||
└── user_store_test.go # testcontainers integration test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `sqlc.yaml`
|
||||
|
||||
```yaml
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "schema.sql"
|
||||
queries: "queries"
|
||||
gen:
|
||||
go:
|
||||
package: "sqlc"
|
||||
out: "sqlc"
|
||||
sql_package: "pgx/v5"
|
||||
emit_json_tags: false
|
||||
emit_prepared_queries: false
|
||||
emit_interface: true # generates a Querier interface
|
||||
emit_exact_table_names: false
|
||||
emit_pointers_for_null_types: true
|
||||
emit_empty_slices: true
|
||||
overrides:
|
||||
- db_type: "uuid"
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: "UUID"
|
||||
- db_type: "timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
```
|
||||
|
||||
Key choices:
|
||||
|
||||
- `sql_package: "pgx/v5"` — generated code uses pgx directly, not `database/sql`. Faster, type-safer.
|
||||
- `emit_interface: true` — generates a `Querier` interface. Lets stores accept either `*pgxpool.Pool` or `pgx.Tx` for transaction support.
|
||||
- `emit_pointers_for_null_types: true` — nullable columns become `*T`, not `sql.NullString`. Cleaner mapping to domain types.
|
||||
- `overrides` for `uuid` → `google/uuid.UUID` and `timestamptz` → `time.Time`.
|
||||
|
||||
---
|
||||
|
||||
## `schema.sql`
|
||||
|
||||
```sql
|
||||
-- internal/store/schema.sql
|
||||
-- The CUMULATIVE schema sqlc parses. Not migrations — the end state.
|
||||
-- Regenerate from a fresh DB via `pg_dump --schema-only`, or hand-maintain.
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_created_at ON users(created_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `queries/users.sql`
|
||||
|
||||
```sql
|
||||
-- name: GetUser :one
|
||||
SELECT id, email, username, created_at
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT id, email, username, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (id, email, username)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, username, created_at;
|
||||
|
||||
-- name: UpdateUserEmail :exec
|
||||
UPDATE users
|
||||
SET email = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
```
|
||||
|
||||
sqlc directives:
|
||||
|
||||
- `:one` — exactly one row; returns `(T, error)`. Returns `pgx.ErrNoRows` on miss.
|
||||
- `:many` — zero or more rows; returns `([]T, error)`.
|
||||
- `:exec` — no rows returned; returns `error`.
|
||||
- `:execrows` — returns `(int64, error)` with affected row count.
|
||||
- `:batchone` / `:batchmany` / `:batchexec` — pgx batch mode for bulk operations.
|
||||
|
||||
Run `task gen:sqlc` (or `sqlc generate`). The generated file is committed; CI checks it is up-to-date.
|
||||
|
||||
---
|
||||
|
||||
## Generated code shape (`sqlc/users.sql.go`)
|
||||
|
||||
```go
|
||||
// GENERATED — do not edit
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, email, username, created_at FROM users WHERE id = $1`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUser, id)
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Email, &u.Username, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
```
|
||||
|
||||
Type-safe inputs, type-safe outputs, compile-time-checked column-to-field mapping. **A schema change that drops a column breaks compilation.** Hand-rolled SQL would have failed at runtime.
|
||||
|
||||
---
|
||||
|
||||
## `store/pool.go`
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil { return nil, fmt.Errorf("parse dsn: %w", err) }
|
||||
|
||||
cfg.MaxConns = 25
|
||||
cfg.MinConns = 5
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
cfg.MaxConnIdleTime = 30 * time.Minute
|
||||
cfg.HealthCheckPeriod = 1 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil { return nil, fmt.Errorf("connect: %w", err) }
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
```
|
||||
|
||||
`pgxpool.Pool` is `Querier`-compatible (implements the interface sqlc generates). Same pool flows into sqlc queries unchanged.
|
||||
|
||||
---
|
||||
|
||||
## `store/user_store.go` — domain ↔ sqlc
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/your-org/myservice/internal/domain"
|
||||
"github.com/your-org/myservice/internal/store/sqlc"
|
||||
)
|
||||
|
||||
type UserStore struct {
|
||||
q *sqlc.Queries
|
||||
}
|
||||
|
||||
func NewUserStore(pool *pgxpool.Pool) *UserStore {
|
||||
return &UserStore{q: sqlc.New(pool)}
|
||||
}
|
||||
|
||||
func (s *UserStore) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
|
||||
row, err := s.q.GetUser(ctx, uuid.UUID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("get user %s: %w", id, err)
|
||||
}
|
||||
return rowToDomain(row)
|
||||
}
|
||||
|
||||
func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) {
|
||||
row, err := s.q.CreateUser(ctx, sqlc.CreateUserParams{
|
||||
ID: uuid.UUID(u.ID),
|
||||
Email: u.Email.String(),
|
||||
Username: u.Username.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return rowToDomain(row)
|
||||
}
|
||||
|
||||
func rowToDomain(r sqlc.User) (domain.User, error) {
|
||||
email, err := domain.NewEmail(r.Email)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("db invariant: email %q: %w", r.Email, err)
|
||||
}
|
||||
username, err := domain.NewUsername(r.Username)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("db invariant: username %q: %w", r.Username, err)
|
||||
}
|
||||
return domain.User{
|
||||
ID: domain.UserID(r.ID),
|
||||
Email: email,
|
||||
Username: username,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The wrapping is verbose. **That is the point.** sqlc rows are storage representations; domain types are business representations. Mapping them explicitly is where invariants are enforced.
|
||||
|
||||
`pgx.ErrNoRows` becomes `domain.ErrUserNotFound` — callers never see storage-level errors.
|
||||
|
||||
---
|
||||
|
||||
## Transactions — pgx.Tx satisfies the Querier interface
|
||||
|
||||
```go
|
||||
func (s *UserStore) CreateWithProfile(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
u domain.User,
|
||||
p domain.Profile,
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil { return fmt.Errorf("begin: %w", err) }
|
||||
defer tx.Rollback(ctx) // no-op if Commit succeeded
|
||||
|
||||
q := s.q.WithTx(tx) // sqlc.Queries bound to the tx
|
||||
|
||||
if _, err := q.CreateUser(ctx, /* ... */); err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
if _, err := q.CreateProfile(ctx, /* ... */); err != nil {
|
||||
return fmt.Errorf("create profile: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
Pattern:
|
||||
|
||||
- `defer tx.Rollback(ctx)` immediately after `Begin` — safe even after Commit (returns "tx closed", which we ignore via the unhandled return).
|
||||
- `q.WithTx(tx)` returns a `*Queries` bound to the tx.
|
||||
- Last line: `tx.Commit(ctx)`.
|
||||
|
||||
For nested transactions across multiple stores, accept a `Querier` parameter:
|
||||
|
||||
```go
|
||||
func (s *UserStore) CreateTx(ctx context.Context, q sqlc.Querier, u domain.User) (domain.User, error) {
|
||||
// uses q instead of s.q — caller decides if it's pool or tx
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrations — goose
|
||||
|
||||
```bash
|
||||
goose -dir internal/store/migrations create create_users sql
|
||||
```
|
||||
|
||||
```sql
|
||||
-- migrations/20260101000001_create_users.sql
|
||||
-- +goose Up
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE users;
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
goose -dir internal/store/migrations postgres "$DATABASE_URL" up
|
||||
goose -dir internal/store/migrations postgres "$DATABASE_URL" status
|
||||
goose -dir internal/store/migrations postgres "$DATABASE_URL" down
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One DDL change per migration. Never combine schema + data migrations in one file.
|
||||
- `Down` is real, not a stub. CI runs `up` → `down` → `up` on a fresh container to prove reversibility.
|
||||
- Migrations are append-only. Never edit a merged migration; add a new one.
|
||||
|
||||
`goose` can run programmatically as well:
|
||||
|
||||
```go
|
||||
import "github.com/pressly/goose/v3"
|
||||
|
||||
if err := goose.UpContext(ctx, db, "migrations"); err != nil { ... }
|
||||
```
|
||||
|
||||
Useful for tools that own their schema (CI runner, integration test setup).
|
||||
|
||||
---
|
||||
|
||||
## Integration tests — testcontainers
|
||||
|
||||
```go
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
pgC, err := postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("test"),
|
||||
postgres.WithUsername("test"),
|
||||
postgres.WithPassword("test"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = pgC.Terminate(ctx) })
|
||||
|
||||
dsn, err := pgC.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
pool, err := store.NewPool(ctx, dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
require.NoError(t, goose.UpContext(ctx, /* sql.DB from pool */, "../migrations"))
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestUserStore_Create_returns_new_user(t *testing.T) {
|
||||
// Given
|
||||
pool := newTestDB(t)
|
||||
s := store.NewUserStore(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
// When
|
||||
user, err := s.Create(ctx, domain.User{
|
||||
ID: domain.UserID(uuid.Must(uuid.NewV7())),
|
||||
Email: mustEmail("a@b.com"),
|
||||
Username: mustUsername("alice"),
|
||||
})
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, user.ID)
|
||||
|
||||
fetched, err := s.Get(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.Email, fetched.Email)
|
||||
}
|
||||
```
|
||||
|
||||
testcontainers spins a real Postgres in Docker, runs migrations, hands you a pool. Tests are slow (~2s startup) but **real** — no fake that diverges from production.
|
||||
|
||||
For test suites with many cases, share one container across tests in the same package via `TestMain`:
|
||||
|
||||
```go
|
||||
var testPool *pgxpool.Pool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ctx := context.Background()
|
||||
pgC, _ := postgres.Run(ctx, "postgres:16-alpine", /* ... */)
|
||||
defer pgC.Terminate(ctx)
|
||||
dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
|
||||
testPool, _ = store.NewPool(ctx, dsn)
|
||||
// run migrations once
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
```
|
||||
|
||||
Each test then uses a transaction it rolls back at the end — fast and isolated.
|
||||
|
||||
---
|
||||
|
||||
## Why NOT gorm
|
||||
|
||||
| Concern | gorm | sqlc + pgx |
|
||||
|---|---|---|
|
||||
| Type safety | runtime reflection; column-to-field via tags | compile-time-checked from SQL |
|
||||
| Performance | 2–5x slower than pgx | pgx is the fastest Go pg driver |
|
||||
| N+1 queries | encouraged by `Preload` API | explicit JOIN in `.sql` |
|
||||
| Migrations | AutoMigrate (unsafe in prod) | goose, explicit |
|
||||
| Debugging | "what query did it run?" requires logging | the query IS the source |
|
||||
| Cancellation | spotty ctx support | first-class |
|
||||
| Active development | Yes but with churn and breaking changes | sqlc is stable |
|
||||
|
||||
Existing gorm projects: leave them. New code: sqlc + pgx.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- sqlc docs: https://docs.sqlc.dev
|
||||
- pgx: https://github.com/jackc/pgx
|
||||
- goose: https://github.com/pressly/goose
|
||||
- testcontainers-go: https://golang.testcontainers.org
|
||||
- pgx pool config: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config
|
||||
@@ -0,0 +1,467 @@
|
||||
# Testing
|
||||
|
||||
TDD shape, table-driven tests, `require` vs `assert`, snapshot tests, property-based tests, integration tests with testcontainers, goroutine-leak detection. The discipline in `programming/SKILL.md` (Given/When/Then, less mock the better, efficient AND accurate) — this document gives the Go-specific recipes.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Assertions | `stretchr/testify/require` (and `assert` only inside table loops) |
|
||||
| Mocks | `go.uber.org/mock` (gomock successor) |
|
||||
| Goroutine leaks | `go.uber.org/goleak` |
|
||||
| Snapshots / golden | `hexops/autogold/v2` |
|
||||
| Property-based | `pgregory.net/rapid` |
|
||||
| HTTP mocks (outbound) | `h2non/gock` |
|
||||
| HTTP test server (inbound) | stdlib `net/http/httptest` |
|
||||
| Integration containers | `testcontainers/testcontainers-go` |
|
||||
| TUI | `charm.land/bubbletea/v2/teatest` |
|
||||
| Bench tooling | stdlib `testing.B` + `perf.dev/benchstat` |
|
||||
|
||||
---
|
||||
|
||||
## Test naming — Given / When / Then in the name
|
||||
|
||||
```go
|
||||
// ──── PATTERN ────
|
||||
// Test_<Subject>_<Outcome>_when_<Condition>
|
||||
// OR
|
||||
// Test_<Subject>_<Action>_<ExpectedOutcome>
|
||||
|
||||
func Test_Email_NewEmail_lowercases_input(t *testing.T)
|
||||
func Test_Email_NewEmail_rejects_input_without_at_sign(t *testing.T)
|
||||
func Test_UserService_Create_persists_user_when_inputs_valid(t *testing.T)
|
||||
func Test_UserService_Create_returns_validation_error_when_email_invalid(t *testing.T)
|
||||
```
|
||||
|
||||
A test name should answer "what behavior is this asserting?" without reading the body. Names that need a comment to explain them are misnamed.
|
||||
|
||||
---
|
||||
|
||||
## Single test — explicit Given/When/Then
|
||||
|
||||
```go
|
||||
func Test_Email_NewEmail_rejects_input_without_at_sign(t *testing.T) {
|
||||
// Given
|
||||
raw := "not-an-email"
|
||||
|
||||
// When
|
||||
_, err := domain.NewEmail(raw)
|
||||
|
||||
// Then
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, domain.ErrInvalidEmail)
|
||||
}
|
||||
```
|
||||
|
||||
`require.*` fails the test immediately on miss. Use `require` for preconditions and primary assertions. Use `assert.*` only inside table-driven loops where you want all cases to report.
|
||||
|
||||
---
|
||||
|
||||
## Table-driven tests
|
||||
|
||||
```go
|
||||
func Test_Email_NewEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{"lowercases", "ALICE@example.com", "alice@example.com", nil},
|
||||
{"trims whitespace", " bob@example.com ", "bob@example.com", nil},
|
||||
{"rejects missing @", "no-at-sign", "", domain.ErrInvalidEmail},
|
||||
{"rejects empty", "", "", domain.ErrInvalidEmail},
|
||||
{"rejects too long", strings.Repeat("a", 256) + "@e.com", "", domain.ErrInvalidEmail},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// When
|
||||
got, err := domain.NewEmail(tt.input)
|
||||
|
||||
// Then
|
||||
if tt.wantErr != nil {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One **scenario** per row, not one **assertion** per row.
|
||||
- Subtest names are sentences in lowercase; `t.Run(tt.name, ...)` makes them filterable: `go test -run Test_Email_NewEmail/rejects_missing_@`.
|
||||
- The loop body itself is Given/When/Then in shape.
|
||||
- For Go 1.22+, the loop var capture works correctly without the `tt := tt` shadow line — the `copyloopvar` linter enforces the new style.
|
||||
|
||||
---
|
||||
|
||||
## Less mocks — the priority order
|
||||
|
||||
In Go specifically:
|
||||
|
||||
1. **Real implementation.** Domain types, pure functions, value objects — instantiate them. They are fast.
|
||||
2. **In-memory fake** that satisfies the interface. Has its own test suite proving behavioral parity with the real impl.
|
||||
3. **`httptest.Server`** for HTTP collaborators (real wire, no internet).
|
||||
4. **`testcontainers`** for stateful collaborators (Postgres, Redis, S3-compatible, Kafka).
|
||||
5. **gomock** ONLY for: clocks, randomness, third-party SaaS with no sandbox.
|
||||
|
||||
### Example: an in-memory fake
|
||||
|
||||
```go
|
||||
// Real interface
|
||||
type UserRepo interface {
|
||||
Save(ctx context.Context, u domain.User) error
|
||||
Get(ctx context.Context, id domain.UserID) (domain.User, error)
|
||||
}
|
||||
|
||||
// In-memory fake — production-quality, tested separately
|
||||
type FakeUserRepo struct {
|
||||
mu sync.RWMutex
|
||||
users map[domain.UserID]domain.User
|
||||
}
|
||||
|
||||
func NewFakeUserRepo() *FakeUserRepo {
|
||||
return &FakeUserRepo{users: map[domain.UserID]domain.User{}}
|
||||
}
|
||||
|
||||
func (r *FakeUserRepo) Save(ctx context.Context, u domain.User) error {
|
||||
r.mu.Lock(); defer r.mu.Unlock()
|
||||
r.users[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FakeUserRepo) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
|
||||
r.mu.RLock(); defer r.mu.RUnlock()
|
||||
u, ok := r.users[id]
|
||||
if !ok { return domain.User{}, domain.ErrUserNotFound }
|
||||
return u, nil
|
||||
}
|
||||
```
|
||||
|
||||
The fake has the same observable behavior as the real one. Tests against `FakeUserRepo` survive when the production repo's internals change. Tests against a gomock stub of `UserRepo` break.
|
||||
|
||||
**A test passing against a fake AND a test passing against the real impl is the gold standard.** Run the same test suite twice — once with the fake, once with testcontainers. The fakes earn their keep when the suites diverge.
|
||||
|
||||
### Example: gomock for the unmockable
|
||||
|
||||
```go
|
||||
//go:generate mockgen -source=clock.go -destination=mocks/clock_mock.go -package=mocks
|
||||
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// In a test:
|
||||
ctrl := gomock.NewController(t)
|
||||
clock := mocks.NewMockClock(ctrl)
|
||||
clock.EXPECT().Now().Return(fixedTime).AnyTimes()
|
||||
```
|
||||
|
||||
Mock the narrowest seam. Never mock `UserRepo` if a fake suffices.
|
||||
|
||||
---
|
||||
|
||||
## E2E scenario tests
|
||||
|
||||
```go
|
||||
//go:build e2e
|
||||
|
||||
func Test_E2E_user_can_signup_then_login(t *testing.T) {
|
||||
// Given — full server in a goroutine
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool := newTestDB(t) // testcontainers Postgres
|
||||
server := startServer(t, pool) // real gin engine on a random port
|
||||
defer server.Close()
|
||||
|
||||
client := server.Client()
|
||||
|
||||
// When — sign up
|
||||
resp, err := client.Post(server.URL+"/api/v1/users",
|
||||
"application/json",
|
||||
strings.NewReader(`{"email":"a@b.com","username":"alice","password":"PassWord!23"}`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
// When — log in
|
||||
resp, err = client.Post(server.URL+"/api/v1/auth/login",
|
||||
"application/json",
|
||||
strings.NewReader(`{"email":"a@b.com","password":"PassWord!23"}`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body struct{ Token string `json:"token"` }
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.NotEmpty(t, body.Token)
|
||||
|
||||
// Then — token works on protected endpoint
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/api/v1/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+body.Token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
```
|
||||
|
||||
Patterns:
|
||||
|
||||
- `//go:build e2e` build tag separates slow E2E from fast unit tests. Run with `go test -tags=e2e ./...`.
|
||||
- One narrative per test: "user can sign up then log in". One `Test_E2E_*` per user-visible outcome.
|
||||
- Real DB via testcontainers, real gin engine, real HTTP. **No mocks.** The point is to catch integration bugs.
|
||||
- Bounded context — every E2E gets a `context.WithTimeout` so failures don't hang CI.
|
||||
|
||||
---
|
||||
|
||||
## Goroutine leak detection
|
||||
|
||||
```go
|
||||
package mypkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/prometheus/client_golang/prometheus.(*Registry)..."),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
One line at the top of every package that spawns goroutines. Catches the bug class the race detector cannot.
|
||||
|
||||
---
|
||||
|
||||
## Snapshot / golden tests — `autogold`
|
||||
|
||||
```go
|
||||
import "github.com/hexops/autogold/v2"
|
||||
|
||||
func Test_RenderHelp_matches_snapshot(t *testing.T) {
|
||||
// Given
|
||||
cmd := newRootCmd()
|
||||
|
||||
// When
|
||||
out := captureOutput(t, func() { _ = cmd.Help() })
|
||||
|
||||
// Then
|
||||
autogold.ExpectFile(t, out)
|
||||
}
|
||||
```
|
||||
|
||||
First run: `go test -update ./...` writes `testdata/Test_RenderHelp.golden`. Future runs compare; failures show a diff. Re-approve intentional changes with `-update`.
|
||||
|
||||
**Use snapshots for STRUCTURE, not BEHAVIOR.** Good targets:
|
||||
|
||||
- CLI `--help` output
|
||||
- JSON response shape
|
||||
- Generated SQL queries
|
||||
- Rendered prompts (assert the structure, not exact wording — see SKILL.md prompt-test rule)
|
||||
|
||||
Bad targets: a function's return value where you should `require.Equal` on the actual structure.
|
||||
|
||||
---
|
||||
|
||||
## Property-based tests — `rapid`
|
||||
|
||||
```go
|
||||
import "pgregory.net/rapid"
|
||||
|
||||
func Test_Email_NewEmail_then_String_roundtrips(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
// Given — generate valid emails
|
||||
local := rapid.StringMatching(`[a-z]{3,10}`).Draw(t, "local")
|
||||
domain := rapid.StringMatching(`[a-z]{3,10}\.com`).Draw(t, "domain")
|
||||
raw := local + "@" + domain
|
||||
|
||||
// When
|
||||
e, err := domain.NewEmail(raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then — round-trip property
|
||||
e2, err := domain.NewEmail(e.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e, e2)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`rapid` shrinks failing cases to minimal counterexamples. Use for:
|
||||
|
||||
- Round-trips (parse → serialize → parse).
|
||||
- Algebraic properties (sort produces ordered, dedup is idempotent, JSON marshal/unmarshal is involutive).
|
||||
- Invariants under random input (validator never panics, serializer never produces invalid UTF-8).
|
||||
|
||||
---
|
||||
|
||||
## HTTP testing — `httptest`
|
||||
|
||||
### Server side
|
||||
|
||||
```go
|
||||
func Test_GetUser_returns_user_for_existing_id(t *testing.T) {
|
||||
// Given
|
||||
svc := newSvcWithFake(t)
|
||||
r := gin.New()
|
||||
h := &Handler{Users: svc}
|
||||
h.Mount(r)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/users/u-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// When
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// Then
|
||||
require.Equal(t, 200, rec.Code)
|
||||
var body domain.User
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "u-1", string(body.ID))
|
||||
}
|
||||
```
|
||||
|
||||
### Client side — `httptest.NewServer`
|
||||
|
||||
```go
|
||||
func Test_Client_retries_on_500(t *testing.T) {
|
||||
// Given — fake upstream
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := myclient.New(srv.URL)
|
||||
|
||||
// When
|
||||
err := client.DoSomething(context.Background())
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, calls)
|
||||
}
|
||||
```
|
||||
|
||||
`httptest.NewServer` spins a real HTTP server on a random port. The fake handler implements the upstream contract. Test the client against the contract, not the implementation.
|
||||
|
||||
---
|
||||
|
||||
## Determinism — the cardinal rules
|
||||
|
||||
- **No `time.Sleep` in tests.** If you need delay, you need a Clock injection.
|
||||
- **`go test -shuffle=on`** in every CI run.
|
||||
- **`go test -count=1`** to defeat the cache.
|
||||
- **Subscribe to the event, do not poll for it.** Channels, callbacks, `t.Cleanup` over polling.
|
||||
- **`t.Parallel()`** for tests that share no state. Speeds up large suites by 4-8x.
|
||||
|
||||
A test that fails 1-in-10 runs is a bug, not flake. The race detector + `-shuffle=on` + ordering hygiene catches >95% of "flake".
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks — `testing.B` + `benchstat`
|
||||
|
||||
```go
|
||||
func Benchmark_NewEmail(b *testing.B) {
|
||||
for b.Loop() { // Go 1.24+ idiom, replaces `for i := 0; i < b.N; i++`
|
||||
_, _ = domain.NewEmail("alice@example.com")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test -bench=. -count=10 -benchmem ./... | tee bench.txt
|
||||
benchstat bench.txt # statistical comparison
|
||||
```
|
||||
|
||||
Always `-count=10` for stable means. `-benchmem` reports allocations. A 5%-slower benchmark in one run is noise; 10 runs + benchstat tells you what is real.
|
||||
|
||||
To compare before/after a change:
|
||||
|
||||
```bash
|
||||
git stash
|
||||
go test -bench=. -count=10 ./... > before.txt
|
||||
git stash pop
|
||||
go test -bench=. -count=10 ./... > after.txt
|
||||
benchstat before.txt after.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage — the right target
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test -race -shuffle=on -coverprofile=cover.out ./...
|
||||
go tool cover -html=cover.out -o cover.html
|
||||
```
|
||||
|
||||
**Aim for 80%+ on `internal/domain` and `internal/service`.** Boundary code (handlers, store mappers) is exercised by integration tests, where line coverage understates what is actually verified. Do not chase 100% — the last 5% is usually error paths that need fault-injection to hit.
|
||||
|
||||
The `golangci-lint` config does not enforce a minimum — coverage as a CI gate becomes a goal-displacement metric. Treat it as feedback, not requirement.
|
||||
|
||||
---
|
||||
|
||||
## TUI testing — `teatest`
|
||||
|
||||
```go
|
||||
import teatest "charm.land/bubbletea/v2/teatest"
|
||||
|
||||
func Test_Counter_increments_on_space(t *testing.T) {
|
||||
// Given
|
||||
tm := teatest.NewTestModel(t, initial(), teatest.WithInitialTermSize(80, 24))
|
||||
|
||||
// When
|
||||
tm.Send(tea.KeyPressMsg{Code: ' '})
|
||||
|
||||
// Then
|
||||
final := tm.FinalModel(t).(model)
|
||||
require.Equal(t, 1, final.count)
|
||||
}
|
||||
```
|
||||
|
||||
For full-view regression, snapshot the rendered output via `autogold`.
|
||||
|
||||
---
|
||||
|
||||
## Antipatterns the skill rejects
|
||||
|
||||
| Bad | Why | Good |
|
||||
|---|---|---|
|
||||
| `if got != want { t.Errorf("expected %v got %v", want, got) }` | Reinvents `require.Equal` | Use testify |
|
||||
| `time.Sleep(100 * time.Millisecond)` after triggering async work | Flake | Subscribe to completion signal, bounded await |
|
||||
| `t.Skip(...)` to silence a known failure | Buries the bug | Fix or open an issue; never silently skip |
|
||||
| One mega-test asserting 12 things | First failure hides next 11 | Split by `Then` |
|
||||
| Snapshot-everything | Locks formatting, not behavior | Snapshots for structure, asserts for values |
|
||||
| Mock every collaborator | Test asserts implementation, not behavior | Real or fake, never mock everything |
|
||||
| Test calls private function via `_test.go` in same package only | Couples test to implementation | Test through the public surface |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- testify: https://github.com/stretchr/testify
|
||||
- goleak: https://github.com/uber-go/goleak
|
||||
- autogold: https://github.com/hexops/autogold
|
||||
- rapid: https://pkg.go.dev/pgregory.net/rapid
|
||||
- testcontainers-go: https://golang.testcontainers.org
|
||||
- benchstat: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
|
||||
- "Go test naming conventions" (Dave Cheney): https://dave.cheney.net/practical-go/presentations/qcon-china.html
|
||||
@@ -0,0 +1,298 @@
|
||||
# Type Patterns
|
||||
|
||||
How to use Go's *limited* type system to catch bugs at compile time. Go gives you fewer tools than Python/TS/Rust — this document covers the four patterns that buy back most of the safety.
|
||||
|
||||
The four patterns:
|
||||
|
||||
1. **Named types** for branding primitives (the Go answer to `NewType` / branded TS).
|
||||
2. **Smart constructors with unexported fields** for parse-don't-validate.
|
||||
3. **Sealed interfaces** for sum types, with `type switch` + `exhaustive` linter.
|
||||
4. **Generics with constraints** for bounded polymorphism (1.18+).
|
||||
|
||||
---
|
||||
|
||||
## 1. Named types — distinct primitives
|
||||
|
||||
Same underlying type, different meaning. The Go type checker prevents *implicit* mixing — but explicit conversion is always possible. Treat this as a contract enforced at boundaries.
|
||||
|
||||
```go
|
||||
package domain
|
||||
|
||||
type UserID string
|
||||
type OrderID string
|
||||
type EmailRaw string // raw, unvalidated string from input
|
||||
|
||||
func GetUser(id UserID) User { /* ... */ }
|
||||
|
||||
uid := UserID("u-123")
|
||||
oid := OrderID("o-456")
|
||||
|
||||
GetUser(uid) // ✅ OK
|
||||
GetUser(oid) // ❌ cannot use oid (type OrderID) as UserID
|
||||
GetUser("u-123") // ❌ untyped string literal — Go DOES catch this
|
||||
GetUser(UserID("u-123")) // ✅ explicit conversion — accept it
|
||||
```
|
||||
|
||||
**Use when**: IDs, opaque tokens, foreign keys, units that share a base primitive.
|
||||
|
||||
**Reality check**: Go does NOT prevent `UserID(orderIDAsString)`. The defense is **smart constructors** for everything beyond an internal identifier. Use named types for cheap brand-only protection; combine with constructors for protection that actually holds.
|
||||
|
||||
### Time-of-day units
|
||||
|
||||
```go
|
||||
type Milliseconds int64
|
||||
type Seconds int64
|
||||
|
||||
func (ms Milliseconds) ToSeconds() Seconds {
|
||||
return Seconds(ms / 1000)
|
||||
}
|
||||
```
|
||||
|
||||
No implicit `Milliseconds + Seconds`. The compiler refuses. Convert explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Smart constructors with unexported fields — the Go answer to Pydantic/Zod
|
||||
|
||||
The single most important pattern in this document. **Go has no Pydantic. It has this.**
|
||||
|
||||
```go
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEmail = errors.New("invalid email")
|
||||
emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
)
|
||||
|
||||
// Email is a parsed, lowercased, valid email address.
|
||||
// The zero value is invalid; construct via NewEmail.
|
||||
type Email struct {
|
||||
raw string // unexported — cannot be set from outside the package
|
||||
}
|
||||
|
||||
func NewEmail(s string) (Email, error) {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if !emailRe.MatchString(s) {
|
||||
return Email{}, ErrInvalidEmail
|
||||
}
|
||||
return Email{raw: s}, nil
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer for printing.
|
||||
func (e Email) String() string { return e.raw }
|
||||
|
||||
// MarshalJSON keeps the wire format unchanged.
|
||||
func (e Email) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + e.raw + `"`), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON is the parsing boundary — strict mode.
|
||||
func (e *Email) UnmarshalJSON(data []byte) error {
|
||||
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
parsed, err := NewEmail(string(data[1 : len(data)-1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*e = parsed
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works**:
|
||||
|
||||
- `Email{raw: "anything"}` from outside the `domain` package is a compile error — `raw` is unexported.
|
||||
- The only way to obtain a non-zero `Email` is `NewEmail(...)`, which validates.
|
||||
- `UnmarshalJSON` routes wire input through the same constructor — boundary parsing is automatic.
|
||||
- Once a function signature has `email Email`, the caller has *proven* it is valid. No internal `if email == ""` checks.
|
||||
|
||||
**Use for every domain value that has invariants**: emails, URLs, phone numbers, currency amounts, percentages, semver versions, IDs with format constraints, time ranges, anything you currently validate in three places.
|
||||
|
||||
### The "zero value problem"
|
||||
|
||||
Go's zero value (`Email{}`) is reachable. The mitigation is documentation + a `IsValid()` method when needed:
|
||||
|
||||
```go
|
||||
func (e Email) IsZero() bool { return e.raw == "" }
|
||||
```
|
||||
|
||||
Or accept it: receivers that take `Email` should *never* receive a zero-value `Email` in correct code. Tests verify it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sealed interfaces — sum types in Go
|
||||
|
||||
Go has no sum types. The closest thing: an interface with an **unexported method** that only types in the same package can satisfy, dispatched via `type switch`, with the `exhaustive` linter ensuring completeness.
|
||||
|
||||
```go
|
||||
package event
|
||||
|
||||
// Event is a closed sum: Created | Updated | Deleted.
|
||||
// The sealed() method is unexported so external packages cannot add variants.
|
||||
type Event interface {
|
||||
sealed()
|
||||
OccurredAt() time.Time
|
||||
}
|
||||
|
||||
type Created struct {
|
||||
UserID UserID
|
||||
Email Email
|
||||
Timestamp time.Time
|
||||
}
|
||||
func (Created) sealed() {}
|
||||
func (e Created) OccurredAt() time.Time { return e.Timestamp }
|
||||
|
||||
type Updated struct {
|
||||
UserID UserID
|
||||
Changes map[string]any
|
||||
Timestamp time.Time
|
||||
}
|
||||
func (Updated) sealed() {}
|
||||
func (e Updated) OccurredAt() time.Time { return e.Timestamp }
|
||||
|
||||
type Deleted struct {
|
||||
UserID UserID
|
||||
Reason string
|
||||
Timestamp time.Time
|
||||
}
|
||||
func (Deleted) sealed() {}
|
||||
func (e Deleted) OccurredAt() time.Time { return e.Timestamp }
|
||||
```
|
||||
|
||||
Consumer code:
|
||||
|
||||
```go
|
||||
func Render(e event.Event) string {
|
||||
switch v := e.(type) {
|
||||
case event.Created:
|
||||
return fmt.Sprintf("created %s with %s", v.UserID, v.Email)
|
||||
case event.Updated:
|
||||
return fmt.Sprintf("updated %s: %v", v.UserID, v.Changes)
|
||||
case event.Deleted:
|
||||
return fmt.Sprintf("deleted %s (reason: %s)", v.UserID, v.Reason)
|
||||
default:
|
||||
panic(fmt.Sprintf("unhandled event variant: %T", v))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `panic` in `default` is the Go equivalent of TS's `assertNever` or Python's `assert_never`. It is only reachable if a new variant is added without updating the switch.
|
||||
|
||||
### The `exhaustive` linter — your compiler
|
||||
|
||||
```yaml
|
||||
# .golangci.yml
|
||||
linters:
|
||||
enable: [exhaustive]
|
||||
linters-settings:
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
default-signifies-exhaustive: false
|
||||
```
|
||||
|
||||
Now adding `event.Suspended` without updating `Render` is a **lint error**. This is the closest thing Go has to Rust's match exhaustiveness check. **Treat it as compulsory.**
|
||||
|
||||
### Sealed interface gotchas
|
||||
|
||||
- The method MUST be unexported (`sealed()`, not `Sealed()`). Otherwise other packages can implement it.
|
||||
- `type switch` with `*Created` vs `Created` matters — pick value receivers and value cases, or pointer receivers and pointer cases. **Mixing them causes silent miss.**
|
||||
- `interface{}` is not a sealed type. Anything implementing zero methods satisfies it. Sealed interfaces have at least the `sealed()` method.
|
||||
|
||||
---
|
||||
|
||||
## 4. Generics with constraints — bounded polymorphism
|
||||
|
||||
Go 1.18+. Use for genuinely generic algorithms; **do not** use for "I want this to accept anything".
|
||||
|
||||
```go
|
||||
import "cmp"
|
||||
|
||||
// Ordered constraint includes all ordered types (int, float, string, …).
|
||||
func Max[T cmp.Ordered](a, b T) T {
|
||||
if a > b { return a }
|
||||
return b
|
||||
}
|
||||
|
||||
// Custom constraint
|
||||
type Stringer interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
func Join[T Stringer](items []T, sep string) string {
|
||||
parts := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
parts[i] = item.String()
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
```
|
||||
|
||||
The `cmp.Ordered` (Go 1.21+), `cmp.Compare`, and `slices`/`maps` packages cover the common cases without you writing constraints.
|
||||
|
||||
### When NOT to use generics
|
||||
|
||||
- "I want to accept multiple types, so I'll make it generic." Use an **interface** instead. Generics are for parametric polymorphism (same code, different types). Interfaces are for behavioral polymorphism (different code behind a contract).
|
||||
- "I want to return `any`." Use a sealed interface and a `type switch`. `any` returns are anti-patterns past public APIs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Type assertions — the controlled escape hatch
|
||||
|
||||
```go
|
||||
// Bad — panics on failure
|
||||
e := evt.(event.Created)
|
||||
|
||||
// Good — comma-ok form, always
|
||||
if e, ok := evt.(event.Created); ok {
|
||||
// use e
|
||||
}
|
||||
|
||||
// Use errors.As for error chains
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
// pgErr is the wrapped pg error
|
||||
}
|
||||
```
|
||||
|
||||
**The `errcheck` and `errorlint` linters reject bare type assertions on `error` values.** Use `errors.As`. See `error-handling.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pointers vs values — the only durable rule
|
||||
|
||||
You will see endless debates. The rule that holds up:
|
||||
|
||||
- **If a type has a mutex, never copy it.** Use `*T` everywhere.
|
||||
- **If a type is large (> 64 bytes) and read-only, pass by value or pointer is a measured choice.** Default to pointer for "large" things.
|
||||
- **Receivers must be consistent.** All methods on `T` either take `T` or `*T`. Don't mix. The `staticcheck` linter catches mixed-receiver bugs.
|
||||
- **`nil` pointer = absence. Zero value = "not set yet".** Choose ONE convention per type. Document it.
|
||||
|
||||
---
|
||||
|
||||
## 7. `any` / `interface{}` — when it is acceptable
|
||||
|
||||
Almost never in domain code. Acceptable cases:
|
||||
|
||||
- JSON parsing of genuinely heterogeneous payloads (and even then, prefer `json.RawMessage` + targeted parsing).
|
||||
- `fmt.Sprintf` arguments (variadic `any` is unavoidable here).
|
||||
- Generic container internals before the user-facing API.
|
||||
|
||||
The skill rejects `any` in handler signatures, service signatures, store signatures. If you find yourself writing `func Handle(payload any) error`, you have a sealed-interface waiting to happen.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- "Parse, don't validate" — Alexis King: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
|
||||
- exhaustive linter: https://github.com/nishanths/exhaustive
|
||||
- Generics constraints: https://go.dev/blog/intro-generics
|
||||
- cmp.Ordered: https://pkg.go.dev/cmp
|
||||
@@ -0,0 +1,314 @@
|
||||
|
||||
# Python Programmer
|
||||
|
||||
Modern Python. Type-strict, stack-first, async-correct.
|
||||
|
||||
## Philosophy
|
||||
|
||||
The type checker is your compiler. Make illegal states unrepresentable. Parse at boundaries. Own resources explicitly. Every function has a contract; the type system enforces it.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are deliberate project choices. Violations are always wrong, not "style preferences".
|
||||
|
||||
### Tooling
|
||||
|
||||
| Category | Use | Never |
|
||||
|---|---|---|
|
||||
| Package manager | `uv` | pip, poetry, conda, pipenv |
|
||||
| Type checker | `basedpyright` (`typeCheckingMode = "all"`) | pyright, mypy |
|
||||
| Linter + formatter | `ruff` (`select = ["ALL"]`) | flake8, black, isort, autopep8 |
|
||||
| Async runtime | `anyio` | `import asyncio` |
|
||||
| Data | `polars` + `duckdb` + `numpy` | pandas |
|
||||
| Web framework | FastAPI + Pydantic v2 | Flask, Django REST |
|
||||
| ORM | SQLAlchemy 2.x async | Django ORM, Tortoise |
|
||||
| HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) | requests, aiohttp, httpx |
|
||||
| Testing | `pytest` | unittest |
|
||||
| CLI | `typer` + `rich` | argparse, click, fire |
|
||||
|
||||
### The iron list
|
||||
|
||||
1. **Frozen by default** — `@dataclass(frozen=True, slots=True)`. Pydantic: `model_config = ConfigDict(frozen=True)`. Mutable only when mutation is the documented purpose.
|
||||
2. **NewType for distinct IDs** — `UserId = NewType("UserId", int)`. Never pass raw `int` where a branded type exists.
|
||||
3. **`match` only for variants, `if` only for booleans** — **NEVER** use `if/elif/else` to discriminate on type (`isinstance`), enum value, or literal variant. `match/case` is mandatory for these — non-negotiable. **ALWAYS** end with `case unreachable: assert_never(unreachable)` — bare `case _: pass` and `case _: raise ValueError` are banned (they silently swallow new variants). `if/else` is fine only for boolean expressions, range checks, and predicate calls that aren't variant discrimination. See "Why `if/elif` on variants is banned" below for examples.
|
||||
4. **Protocol over ABC** — `typing.Protocol` for interfaces. ABC only when you need shared method implementation.
|
||||
5. **No raw dicts in signatures** — params and returns use `TypedDict`, `dataclass`, or Pydantic model. Internal scratch dicts are fine.
|
||||
6. **Parse, don't validate** — constructors produce typed objects or raise. Never pass unvalidated data deeper into the call stack.
|
||||
7. **Typed errors** — error types are dataclasses or exceptions with typed fields. Never `raise ValueError("something")` with a bare string. Use union returns when the caller is within 1-2 call levels and must handle the outcome (repository → service). Use exceptions when the error should propagate up many layers to a boundary handler (service → HTTP handler).
|
||||
8. **Final for constants** — module-level constants use `Final`. Mutable module globals are a code smell.
|
||||
9. **Explicit None** — annotate `-> X | None`. Never return `None` from a function whose signature omits it.
|
||||
10. **Context managers for resources** — files, DB connections, HTTP clients, locks. No manual `.close()`.
|
||||
11. **No Any, no object** — both are banned as type annotations. `object` erases all structural information (zero callable attributes, zero narrowing). Use `Protocol` (structural typing), `TypeVar` (generic pass-through), explicit union (known variants), or `TypedDict` (dict shapes).
|
||||
12. **No cast** — `cast()` is banned. Redesign the types.
|
||||
13. **No type: ignore** — fix the type error. The checker is right; you are wrong.
|
||||
14. **No broad except** — `except Exception` and `except BaseException` are banned. Catch the **specific** exception you expect. A broad catch swallows bugs you need to see — `KeyError`, `AttributeError`, `TypeError` all vanish silently. If you genuinely need a catch-all at a top-level boundary (CLI entry, HTTP handler), use `# noqa: BROAD_EXCEPT_OK` and log + re-raise.
|
||||
|
||||
### Typing and safety
|
||||
|
||||
- `basedpyright` in `typeCheckingMode = "all"`. Every public function has full annotations. Internal helpers: annotate return type; parameter types may be inferred.
|
||||
- `ruff` with `select = ["ALL"]`. Override specific rules per project in `pyproject.toml`, never globally disable the strict baseline.
|
||||
- Every new function must have a `docstring` unless its name + signature makes it completely obvious (e.g. `def full_name(first: str, last: str) -> str:`).
|
||||
- Use `X | Y` union syntax (PEP 604), never `Union[X, Y]` or `Optional[X]`.
|
||||
|
||||
### Why `object` is banned
|
||||
|
||||
`object` pretends to be safe ("it's the top type!") but gives **zero** narrowing and **zero** attributes. Even `Any` is more honest — it admits the boundary is untyped.
|
||||
|
||||
```python
|
||||
# BANNED
|
||||
def process(data: object) -> object: ...
|
||||
def store(items: list[object]) -> None: ...
|
||||
results: dict[str, object] = {}
|
||||
|
||||
# GOOD — Protocol for structural typing
|
||||
class Serializable(Protocol):
|
||||
def serialize(self) -> bytes: ...
|
||||
def process(data: Serializable) -> ProcessResult: ...
|
||||
|
||||
# GOOD — TypeVar for generic pass-through
|
||||
def identity[T](x: T) -> T: ...
|
||||
def first[T](items: Sequence[T]) -> T: ...
|
||||
|
||||
# GOOD — explicit union for known variants
|
||||
def parse(raw: str | bytes) -> Document: ...
|
||||
```
|
||||
|
||||
### Why `if/elif` on variants is banned
|
||||
|
||||
`if/elif/else` chains on type, enum, or literal values lose compile-time exhaustiveness. When a new variant is added, nothing warns you. `match/case` + `assert_never` does.
|
||||
|
||||
```python
|
||||
# BANNED — if/elif for type discrimination
|
||||
if isinstance(event, Click):
|
||||
handle_click(event.x, event.y)
|
||||
elif isinstance(event, Scroll):
|
||||
handle_scroll(event.delta)
|
||||
else:
|
||||
raise ValueError(f"Unknown: {event}") # runtime bomb
|
||||
|
||||
# BANNED — if/elif for enum discrimination
|
||||
if status == Status.PENDING:
|
||||
start_review()
|
||||
elif status == Status.ACTIVE:
|
||||
continue_processing()
|
||||
elif status == Status.CLOSED:
|
||||
archive()
|
||||
|
||||
# BANNED — non-exhaustive match (swallows new variants)
|
||||
match event:
|
||||
case Click(x, y): handle_click(x, y)
|
||||
case _: pass
|
||||
|
||||
# GOOD — exhaustive match with assert_never
|
||||
match event:
|
||||
case Click(x=x, y=y):
|
||||
handle_click(x, y)
|
||||
case Scroll(delta=delta):
|
||||
handle_scroll(delta)
|
||||
case unreachable:
|
||||
assert_never(unreachable)
|
||||
|
||||
# GOOD — enum match
|
||||
match status:
|
||||
case Status.PENDING: start_review()
|
||||
case Status.ACTIVE: continue_processing()
|
||||
case Status.CLOSED: archive()
|
||||
case unreachable: assert_never(unreachable)
|
||||
```
|
||||
|
||||
`if/else` is fine for boolean conditions and range checks — things that aren't variant discrimination:
|
||||
|
||||
```python
|
||||
# FINE — boolean, not variant
|
||||
if age >= 18:
|
||||
grant_access()
|
||||
else:
|
||||
deny_access()
|
||||
```
|
||||
|
||||
### Why broad `except` is banned
|
||||
|
||||
`except Exception` catches **every** non-system exception — `KeyError`, `TypeError`, `AttributeError`, `ValueError` all vanish. You lose the stack trace that would have told you exactly what went wrong. The fix is always to name the exception you expect.
|
||||
|
||||
```python
|
||||
# BANNED — swallows bugs
|
||||
try:
|
||||
result = api.fetch(url)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
# BANNED — catch-and-ignore
|
||||
try:
|
||||
parse(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# GOOD — catch what you expect
|
||||
try:
|
||||
result = api.fetch(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("API %d: %s", e.response.status_code, e.request.url)
|
||||
return None
|
||||
except httpx.ConnectError:
|
||||
raise ServiceUnavailableError(service="api") from None
|
||||
|
||||
# GOOD — top-level boundary (only place broad catch is acceptable)
|
||||
def main() -> int: # noqa: BROAD_EXCEPT_OK
|
||||
try:
|
||||
return run()
|
||||
except Exception:
|
||||
logger.exception("unhandled error")
|
||||
return 1
|
||||
```
|
||||
|
||||
### Async
|
||||
|
||||
- `import asyncio` is **BANNED**. Use `import anyio`.
|
||||
- For background tasks, use `anyio.create_task_group`. Never fire-and-forget with `asyncio.create_task`.
|
||||
- For concurrency gates, use `anyio.CapacityLimiter` (not `asyncio.Semaphore`).
|
||||
- Load `async-anyio.md` when writing async code for the full pattern library.
|
||||
|
||||
### Data modeling — which container, when
|
||||
|
||||
All model fields carry type annotations. No `Any`, no untyped dicts in public APIs.
|
||||
Use `polars` + `duckdb` for data. pandas is never the right answer in this stack.
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| User input, API request/response | `Pydantic BaseModel (frozen=True)` |
|
||||
| Internal value object (no I/O) | `@dataclass(frozen=True, slots=True)` |
|
||||
| Function with multiple outcomes | Union of frozen dataclasses + `match` |
|
||||
| Dict shape for JSON compat / `**kwargs` | `TypedDict` |
|
||||
| Fixed constants | `StrEnum` / `IntEnum` |
|
||||
| Distinct primitive (UserId vs MovieId) | `NewType` |
|
||||
| Contract / capability | `Protocol` |
|
||||
| Contract + shared implementation | `ABC` |
|
||||
| ORM model (SQLAlchemy) | `Mapped[]` — inherently mutable, `# noqa: MUTABLE_OK` |
|
||||
| Config from env vars | `pydantic-settings BaseSettings` |
|
||||
|
||||
**The one rule**: data crosses trust boundary → Pydantic. Everything else → dataclass.
|
||||
|
||||
Load `data-modeling.md` for the full decision flowchart and comparison matrix.
|
||||
|
||||
### When frozen=True does not apply
|
||||
|
||||
- **ORM models** — SQLAlchemy `Mapped[]` requires mutation. Use `# noqa: MUTABLE_OK`.
|
||||
- **Builder / accumulator** — object exists to be mutated (counter, buffer, state machine). Docstring must explain why.
|
||||
- **Pydantic Settings** — tests override fields. Mutable is acceptable.
|
||||
|
||||
If you need `# noqa: MUTABLE_OK`, the class docstring must say why mutation is required.
|
||||
|
||||
### Libraries
|
||||
|
||||
Canonical defaults (override only if `pyproject.toml` explicitly picks something else):
|
||||
|
||||
| Domain | Library | Reason |
|
||||
|---|---|---|
|
||||
| CLI | `typer` | Type-annotated CLI from function sigs |
|
||||
| Pretty output | `rich` | Tables, progress, tracebacks, markdown |
|
||||
| HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) | Next-gen HTTP client (Pydantic stewardship), HTTP/2, brotli+zstd. Always `httpx2[http2,brotli,zstd]`. See `httpx2-optimization.md` |
|
||||
| Validation | `pydantic` v2 | Fast native validator, JSON Schema |
|
||||
| Web API | `fastapi` | Async, Pydantic-native, OpenAPI |
|
||||
| ORM | `sqlalchemy` 2.x async | `Mapped[]` types, async sessions |
|
||||
| DB driver (Postgres) | `asyncpg` (via SQLAlchemy) | Fastest PG driver |
|
||||
| AI agents | `pydantic-ai` | Typed deps, structured output |
|
||||
| TUI | `textual` | Rich-based, CSS layout, widgets |
|
||||
| Logging | `rich.logging.RichHandler` | Pretty; swap to `structlog` in prod |
|
||||
|
||||
## pyproject.toml — the one true config
|
||||
|
||||
Scaffold a new project with all strict defaults pre-configured:
|
||||
|
||||
```bash
|
||||
uv run ../../scripts/python/new-project.py myproject
|
||||
uv run ../../scripts/python/new-project.py myproject --path ./workspace
|
||||
uv run ../../scripts/python/new-project.py myproject --lib # publishable library
|
||||
```
|
||||
|
||||
Creates via `uv init`, then injects basedpyright `typeCheckingMode = "all"` + ruff `select = ["ALL"]` + pytest strict. Cross-platform (macOS, Linux, Windows).
|
||||
|
||||
For manual setup: `uv init --app myproject`, then load `pyproject-strict.md`.
|
||||
|
||||
## PEP 723 — inline script metadata (mandatory for ALL scripts)
|
||||
|
||||
Every `.py` script — even throwaway — MUST use PEP 723 inline metadata with the `# ─── How to run ───` comment block. No venv, no `requirements.txt`. The script IS the environment spec. A script without the usage comment block is incomplete.
|
||||
|
||||
Scaffold with: `uv run ../../scripts/python/new-script.py <name> --deps "httpx2[http2,brotli,zstd]"` (writes to temp dir by default, `--output` for specific path).
|
||||
|
||||
Load `one-liners.md` for full patterns, examples, and anti-patterns.
|
||||
|
||||
## Reference loading
|
||||
|
||||
Load on demand — not all at once.
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| Full pyproject.toml config | `pyproject-strict.md` |
|
||||
| Type patterns (NewType, Final, enums, narrowing) | `type-patterns.md` |
|
||||
| Data modeling (container choice, frozen, parse-don't-validate) | `data-modeling.md` |
|
||||
| Error handling (typed errors, union returns, exhaustive match) | `error-handling.md` |
|
||||
| Async patterns (anyio) | `async-anyio.md` |
|
||||
| Data processing (polars / duckdb) | `data-processing.md` |
|
||||
| FastAPI + SQLAlchemy stack | `fastapi-stack.md` |
|
||||
| Library decision tree | `libraries.md` |
|
||||
| **httpx2 optimization** (MUST load for any network code) | `httpx2-optimization.md` |
|
||||
| **orjson** (when JSON is in the hot path; FastAPI/Pydantic v2 integration) | `orjson-stack.md` |
|
||||
| One-liner scripts (PEP 723) | `one-liners.md` |
|
||||
| PydanticAI agents | `pydantic-ai.md` |
|
||||
| Textual TUI | `textual-tui.md` |
|
||||
|
||||
## httpx2 — mandatory for ALL network requests
|
||||
|
||||
Every outgoing HTTP call MUST use [`httpx2`](https://github.com/pydantic/httpx2) (`httpx2[http2,brotli,zstd]`). Never `requests`, never `aiohttp`, never the original `httpx`.
|
||||
|
||||
**ALL optimizations are ON by default — not optional, not progressive, not "nice to have".** A bare `httpx2.AsyncClient()` is a bug — treat it like a lint violation. The correct way is the factory pattern in `httpx2-optimization.md` with: HTTP/2 enabled, tuned connection pool (200/40/30s), split timeouts (5/30/10/10), transport retries (3), TCP_NODELAY, follow_redirects, and event hooks for observability.
|
||||
|
||||
When writing or reviewing ANY network code, **ALWAYS load `httpx2-optimization.md`** and use the factory pattern verbatim. No exceptions.
|
||||
|
||||
## No-excuse audit
|
||||
|
||||
Violations caught by `../../scripts/python/check-no-excuse-rules.py`. Run after every edit session.
|
||||
|
||||
| Rule ID | Catches | Opt-out |
|
||||
|---|---|---|
|
||||
| `cast-any` | `cast(Any, ...)` | None — redesign types |
|
||||
| `type-ignore` | `# type: ignore` | None — fix the type |
|
||||
| `pyright-ignore` | `# pyright: ignore` | None — fix the type |
|
||||
| `bare-except` | `except:` with no class | None — name the exception |
|
||||
| `silent-except` | `except X: pass` / `except X: ...` | None — handle or re-raise |
|
||||
| `no-asyncio` | `import asyncio` | `# noqa: ANYIO_OK` |
|
||||
| `no-pandas` | `import pandas` | `# noqa: PANDAS_OK` |
|
||||
| `mutable-dataclass` | `@dataclass` without `frozen=True` | `# noqa: MUTABLE_OK` |
|
||||
| `missing-slots` | `@dataclass` without `slots=True` | `# noqa: SLOTS_OK` |
|
||||
| `raw-dict-return` | `-> dict` in function return type | `# noqa: DICT_OK` |
|
||||
| `missing-assert-never` | `match` block without `assert_never` default | `# noqa: MATCH_OK` |
|
||||
| `generic-exception` | `raise ValueError("...")` / `raise TypeError("...")` with bare string | `# noqa: GENERIC_ERR_OK` |
|
||||
| `no-object` | `object` used as type annotation (param, return, generic arg) | `# noqa: OBJECT_OK` |
|
||||
| `if-elif-on-variant` | `if isinstance()`/`if x == Enum.V` chain that should be `match/case` | `# noqa: IF_VARIANT_OK` |
|
||||
| `oversized-module` | File exceeds 250 pure LOC (non-blank, non-comment) | `# noqa: SIZE_OK` |
|
||||
| `broad-except` | `except Exception` / `except BaseException` (too broad) | `# noqa: BROAD_EXCEPT_OK` |
|
||||
|
||||
Fix every violation before declaring work done. basedpyright + ruff strict config catches the rest.
|
||||
|
||||
## In tests
|
||||
|
||||
Tests are strict too, with these exceptions (already configured in `pyproject.toml` per-file-ignores):
|
||||
|
||||
| In tests you may | Why |
|
||||
|---|---|
|
||||
| Use `assert` | That's how pytest works (`S101` ignored) |
|
||||
| Use magic numbers | Test data (`PLR2004` ignored) |
|
||||
| Access `_private` members | Testing internals (`SLF001` ignored) |
|
||||
| Skip docstrings | Test names are the docs (`D` ignored) |
|
||||
| Have unused function args | Fixtures (`ARG` ignored) |
|
||||
|
||||
Tests still follow the iron list — frozen dataclasses, typed errors, exhaustive match. If test fixtures need mutable state, use `# noqa: MUTABLE_OK` on the fixture class.
|
||||
|
||||
## Existing codebases
|
||||
|
||||
When editing an existing file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.** Mixing feature work with style migration makes reviews harder and bugs likelier.
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates whenever you are writing or modifying any `.py` file. Even one-off scripts get the strict treatment — that is the whole point of PEP 723 + uv: production hygiene with throwaway ergonomics.
|
||||
@@ -0,0 +1,442 @@
|
||||
# AnyIO Reference: Replacing asyncio Idioms
|
||||
|
||||
> **Skill mandate**: `import asyncio` is BANNED. Use `import anyio` exclusively.
|
||||
> This reference targets AnyIO 4.x (2026 Python projects).
|
||||
|
||||
---
|
||||
|
||||
## 1. Task Groups (The Core Primitive)
|
||||
|
||||
AnyIO uses **structured concurrency** via task groups. A task group is an async context manager that guarantees all child tasks finish before the block exits.
|
||||
|
||||
### `start_soon` — fire-and-forget
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
async def worker(n: int) -> None:
|
||||
await anyio.sleep(1)
|
||||
print(f"task {n} done")
|
||||
|
||||
async def main() -> None:
|
||||
async with anyio.create_task_group() as tg:
|
||||
for i in range(3):
|
||||
tg.start_soon(worker, i)
|
||||
print("all tasks finished")
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
**Signature**: `tg.start_soon(func, *args, name=None)`
|
||||
- `func` must be a **coroutine function** (not a coroutine object).
|
||||
- `name` is optional, for introspection/debugging.
|
||||
- No return value; exceptions propagate as `ExceptionGroup` on exit.
|
||||
|
||||
### `start` — wait for ready signal
|
||||
|
||||
Use when a task must initialize before the caller proceeds (e.g., starting a server and then connecting to it).
|
||||
|
||||
```python
|
||||
from anyio import TASK_STATUS_IGNORED, create_task_group, run
|
||||
from anyio.abc import TaskStatus
|
||||
|
||||
async def start_server(port: int, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
|
||||
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=port)
|
||||
task_status.started() # unblocks tg.start()
|
||||
await listener.serve(handler)
|
||||
|
||||
async def main() -> None:
|
||||
async with create_task_group() as tg:
|
||||
await tg.start(start_server, 8080) # blocks until task_status.started()
|
||||
# server is guaranteed ready here
|
||||
async with await anyio.connect_tcp("127.0.0.1", 8080) as client:
|
||||
...
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
**Rule of thumb**:
|
||||
- Use `start_soon` when you don't need to know when the task is ready.
|
||||
- Use `start` when the task must signal readiness before you continue.
|
||||
|
||||
### `create_task` — retrieving return values (AnyIO 4.14+)
|
||||
|
||||
```python
|
||||
async def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
async def main() -> None:
|
||||
async with anyio.create_task_group() as tg:
|
||||
handle = tg.create_task(add(2, 4))
|
||||
result = await handle # == 6
|
||||
print(handle.return_value) # also 6
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
**Signature**: `tg.create_task(coro, *, name=None, context=None) -> TaskHandle[T]`
|
||||
- Returns a `TaskHandle` you can `await` for the result.
|
||||
- If the task raises, awaiting raises `TaskFailed` (or `TaskCancelled`).
|
||||
- This is the canonical replacement for `asyncio.gather` when you need results.
|
||||
|
||||
---
|
||||
|
||||
## 2. asyncio → anyio Cheat Sheet
|
||||
|
||||
| asyncio | anyio | Notes |
|
||||
|---------|-------|-------|
|
||||
| `asyncio.gather(a, b, c)` | `tg.create_task(a); tg.create_task(b); tg.create_task(c); results = [await h for h in handles]` | No direct gather; structured concurrency requires explicit task group scope. For fire-and-forget, use `tg.start_soon`. |
|
||||
| `asyncio.create_task(coro)` | `tg.start_soon(func, *args)` or `tg.create_task(coro)` | `start_soon` takes a coroutine **function** + args. `create_task` takes a coroutine **object** and returns a handle. |
|
||||
| `asyncio.sleep(n)` | `anyio.sleep(n)` | Identical semantics. |
|
||||
| `asyncio.wait_for(coro, timeout)` | `with anyio.fail_after(timeout): await coro` | Raises `TimeoutError`. Use `move_on_after` for silent timeout. |
|
||||
| `asyncio.Event()` | `anyio.Event()` | AnyIO events are **not reusable**; create a new one instead of `.clear()`. |
|
||||
| `asyncio.Lock()` | `anyio.Lock()` | Use `async with lock:`. Pass `fast_acquire=True` if performance-critical. |
|
||||
| `asyncio.Semaphore(n)` | `anyio.Semaphore(n)` | Same. Pass `fast_acquire=True` if performance-critical. |
|
||||
| `asyncio.Condition()` | `anyio.Condition()` | Same semantics. |
|
||||
| `asyncio.run(main())` | `anyio.run(main)` | Backend-agnostic entry point. |
|
||||
| `asyncio.Queue(maxsize=N)` | `anyio.create_memory_object_stream[T](max_buffer_size=N)` | Returns `(send_stream, receive_stream)`. Supports `async for` on receive end. |
|
||||
| `asyncio.to_thread(fn, *args)` | `anyio.to_thread.run_sync(fn, *args)` | Supports `abandon_on_cancel=True` and custom `limiter`. |
|
||||
| `asyncio.run_coroutine_threadsafe(coro, loop)` | `anyio.from_thread.run(func, *args)` | Call async code from a worker thread. |
|
||||
| `loop.call_soon_threadsafe(callback)` | `anyio.from_thread.run_sync(func, *args)` | Call sync code in event loop thread from worker thread, **with return value**. |
|
||||
| `asyncio.shield(coro)` | `with anyio.CancelScope(shield=True): ...` | AnyIO shielding does not orphan tasks. |
|
||||
| `asyncio.timeout(delay)` | `with anyio.fail_after(delay): ...` | AnyIO uses level cancellation, not edge cancellation. |
|
||||
| `asyncio.CancelledError` | `anyio.get_cancelled_exc_class()` | Use this to catch cancellation portably across backends. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cancellation & CancelScope
|
||||
|
||||
AnyIO uses **level cancellation** (inspired by Trio), not asyncio's **edge cancellation**.
|
||||
|
||||
- **Edge cancellation** (asyncio): A `CancelledError` is injected once. If caught and not re-raised, the task keeps running.
|
||||
- **Level cancellation** (anyio): As long as a task is inside an effectively cancelled scope, every yield point raises a new cancellation exception.
|
||||
|
||||
### Basic CancelScope
|
||||
|
||||
```python
|
||||
from anyio import CancelScope, create_task_group, get_cancelled_exc_class, sleep, run
|
||||
|
||||
async def worker() -> None:
|
||||
try:
|
||||
await sleep(10)
|
||||
except get_cancelled_exc_class():
|
||||
print("cancelled!")
|
||||
raise # ALWAYS re-raise cancellation exceptions
|
||||
|
||||
async def main() -> None:
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(worker)
|
||||
await sleep(0.1)
|
||||
tg.cancel_scope.cancel() # cancels all children
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
### Shielding
|
||||
|
||||
Shield a block from external cancellation. Essential for cleanup.
|
||||
|
||||
```python
|
||||
from anyio import CancelScope, create_task_group, sleep, run
|
||||
|
||||
async def main() -> None:
|
||||
async with create_task_group() as tg:
|
||||
with CancelScope(shield=True):
|
||||
tg.start_soon(some_task)
|
||||
tg.cancel_scope.cancel() # shielded block is protected
|
||||
await sleep(1) # this still runs
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
**Combine with timeouts for graceful shutdown**:
|
||||
|
||||
```python
|
||||
from anyio import CancelScope, move_on_after
|
||||
|
||||
async def do_something(resource) -> None:
|
||||
try:
|
||||
await run_async_stuff()
|
||||
except BaseException:
|
||||
# Allow up to 10s for cleanup, then move on
|
||||
with move_on_after(10, shield=True):
|
||||
await resource.aclose()
|
||||
raise
|
||||
```
|
||||
|
||||
### Structured Concurrency Guarantee
|
||||
|
||||
A task group contains its own `CancelScope`. If any child task raises an exception:
|
||||
1. The task group's cancel scope is cancelled.
|
||||
2. All other child tasks receive cancellation.
|
||||
3. The task group waits for all children to finish.
|
||||
4. The original exception (wrapped in `ExceptionGroup` if multiple) is re-raised.
|
||||
|
||||
---
|
||||
|
||||
## 4. Timeouts
|
||||
|
||||
Two context managers. Both create a `CancelScope` internally.
|
||||
|
||||
### `fail_after` — raises on timeout
|
||||
|
||||
```python
|
||||
from anyio import fail_after, sleep, run
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
with fail_after(5) as scope:
|
||||
await sleep(10)
|
||||
except TimeoutError:
|
||||
print("timed out")
|
||||
print(scope.cancelled_caught) # True
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
### `move_on_after` — silent timeout
|
||||
|
||||
```python
|
||||
from anyio import move_on_after, sleep, run
|
||||
|
||||
async def main() -> None:
|
||||
with move_on_after(5) as scope:
|
||||
await sleep(10)
|
||||
print("this never prints")
|
||||
|
||||
print("exited scope, cancelled =", scope.cancelled_caught)
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
### Combined with shielding
|
||||
|
||||
```python
|
||||
from anyio import move_on_after
|
||||
|
||||
# Give cleanup 10 seconds, but don't let outer cancellation interrupt it
|
||||
with move_on_after(10, shield=True):
|
||||
await resource.aclose()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Memory Object Streams (Queue Replacement)
|
||||
|
||||
Replaces `asyncio.Queue` with a safer, typed, structured-concurrency-friendly construct.
|
||||
|
||||
```python
|
||||
from anyio import create_task_group, create_memory_object_stream, run
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream
|
||||
|
||||
async def consumer(stream: MemoryObjectReceiveStream[str]) -> None:
|
||||
async with stream: # closes receive end on exit
|
||||
async for item in stream:
|
||||
print("received", item)
|
||||
|
||||
async def main() -> None:
|
||||
# Type-annotated stream creation (AnyIO 4+ syntax)
|
||||
send_stream, receive_stream = create_memory_object_stream[str](max_buffer_size=10)
|
||||
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(consumer, receive_stream)
|
||||
async with send_stream:
|
||||
for i in range(5):
|
||||
await send_stream.send(f"item {i}")
|
||||
# send_stream closed → consumer's async for loop exits naturally
|
||||
|
||||
run(main)
|
||||
```
|
||||
|
||||
**Key differences from `asyncio.Queue`**:
|
||||
- **Bounded by default**: `max_buffer_size=0` means send blocks until a receiver is ready.
|
||||
- **Cloneable**: Each producer/consumer can close its own clone. The stream only ends when **all** clones of one end are closed.
|
||||
- **Async iterable**: `async for item in receive_stream:` works out of the box.
|
||||
- **Type-safe**: Generic `create_memory_object_stream[T]()`.
|
||||
- **Synchronous close**: Both `close()` and `async with` work.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend Selection
|
||||
|
||||
AnyIO is backend-agnostic. Code written against AnyIO APIs runs on both asyncio and Trio.
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
async def main() -> None:
|
||||
print("running on", anyio.current_async_library())
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Default backend (asyncio)
|
||||
anyio.run(main)
|
||||
|
||||
# Explicit backend
|
||||
anyio.run(main, backend="trio")
|
||||
anyio.run(main, backend="asyncio", backend_options={"debug": True})
|
||||
```
|
||||
|
||||
**Library design rule**: Never hardcode a backend. Let the application choose via `anyio.run()`. Libraries should only import `anyio` and avoid backend-specific APIs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Compatibility with asyncio-only libraries
|
||||
|
||||
### Using asyncio libraries under the asyncio backend
|
||||
|
||||
If a third-party library exposes only an asyncio interface (returns asyncio coroutine objects), it works directly under the asyncio backend because AnyIO runs on top of asyncio's event loop:
|
||||
|
||||
```python
|
||||
import anyio
|
||||
import some_asyncio_only_lib # returns asyncio.Future/coroutine objects
|
||||
|
||||
async def main() -> None:
|
||||
# This works because under the asyncio backend, await passes through
|
||||
result = await some_asyncio_only_lib.fetch_data()
|
||||
|
||||
anyio.run(main, backend="asyncio")
|
||||
```
|
||||
|
||||
**Important**: This only works on the `asyncio` backend. On the `trio` backend, asyncio-native objects will not work.
|
||||
|
||||
### When you MUST use asyncio APIs
|
||||
|
||||
Some APIs have no AnyIO equivalent and require direct event loop access:
|
||||
|
||||
| Scenario | asyncio API | AnyIO approach |
|
||||
|----------|-------------|----------------|
|
||||
| Signal handlers | `loop.add_signal_handler()` | `anyio.open_signal_receiver()` |
|
||||
| Custom protocols | `asyncio.Protocol` | Use AnyIO streams / sockets |
|
||||
| Direct Future manipulation | `asyncio.Future` | Avoid; use AnyIO primitives |
|
||||
| Eager task factories | `asyncio.eager_task_factory` | Experimental in AnyIO; avoid |
|
||||
|
||||
If you absolutely need the running loop:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def main() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
# ... do something loop-specific ...
|
||||
# WARNING: this breaks backend-agnosticism
|
||||
|
||||
anyio.run(main, backend="asyncio")
|
||||
```
|
||||
|
||||
**Best practice**: Wrap asyncio-only code in a backend-agnostic facade, and document that the feature requires the asyncio backend.
|
||||
|
||||
---
|
||||
|
||||
## 8. Idiomatic Code Snippets
|
||||
|
||||
### Snippet 1: Parallel HTTP requests with timeout and cleanup
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
async def fetch(url: str) -> bytes:
|
||||
await anyio.sleep(0.5) # simulate
|
||||
return b"data"
|
||||
|
||||
async def main() -> None:
|
||||
urls = ["a", "b", "c"]
|
||||
async with anyio.create_task_group() as tg:
|
||||
with anyio.move_on_after(5):
|
||||
for url in urls:
|
||||
tg.start_soon(fetch, url)
|
||||
# All tasks are cancelled on timeout; task group waits for cleanup
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
### Snippet 2: Producer-consumer with memory object stream
|
||||
|
||||
```python
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream
|
||||
|
||||
async def producer(send_stream: anyio.streams.memory.MemoryObjectSendStream[int]) -> None:
|
||||
async with send_stream:
|
||||
for i in range(100):
|
||||
await send_stream.send(i)
|
||||
|
||||
async def consumer(receive_stream: MemoryObjectReceiveStream[int]) -> None:
|
||||
async with receive_stream:
|
||||
async for item in receive_stream:
|
||||
print(f"consumed {item}")
|
||||
|
||||
async def main() -> None:
|
||||
send, receive = anyio.create_memory_object_stream[int](max_buffer_size=5)
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(producer, send)
|
||||
tg.start_soon(consumer, receive)
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
### Snippet 3: Calling sync code from async
|
||||
|
||||
```python
|
||||
import time
|
||||
import anyio
|
||||
|
||||
async def main() -> None:
|
||||
# Run blocking function in worker thread
|
||||
result = await anyio.to_thread.run_sync(time.sleep, 2)
|
||||
print("done")
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
### Snippet 4: Calling async code from a worker thread
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
def blocking_callback() -> None:
|
||||
# Inside a worker thread, call back into the event loop
|
||||
anyio.from_thread.run(anyio.sleep, 1)
|
||||
anyio.from_thread.run_sync(print, "hello from thread")
|
||||
|
||||
async def main() -> None:
|
||||
await anyio.to_thread.run_sync(blocking_callback)
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
### Snippet 5: Graceful shutdown with shielded cleanup
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
async def worker() -> None:
|
||||
try:
|
||||
await anyio.sleep_forever()
|
||||
except anyio.get_cancelled_exc_class():
|
||||
with anyio.CancelScope(shield=True):
|
||||
await anyio.sleep(0.5) # cleanup
|
||||
print("cleaned up")
|
||||
raise
|
||||
|
||||
async def main() -> None:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(worker)
|
||||
await anyio.sleep(1)
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- AnyIO Documentation (stable): https://anyio.readthedocs.io/en/stable/
|
||||
- AnyIO GitHub (HEAD `cb245dba`): https://github.com/agronholm/anyio
|
||||
- Task Groups: https://anyio.readthedocs.io/en/stable/tasks.html
|
||||
- Cancellation & Timeouts: https://anyio.readthedocs.io/en/stable/cancellation.html
|
||||
- Streams: https://anyio.readthedocs.io/en/stable/streams.html
|
||||
- Synchronization: https://anyio.readthedocs.io/en/stable/synchronization.html
|
||||
- Threads: https://anyio.readthedocs.io/en/stable/threads.html
|
||||
- Basics / Backends: https://anyio.readthedocs.io/en/stable/basics.html
|
||||
- Design Rationale (why asyncio is problematic): https://anyio.readthedocs.io/en/stable/why.html
|
||||
@@ -0,0 +1,233 @@
|
||||
# Data Modeling
|
||||
|
||||
Which container to use, how to structure data, and why frozen is the default.
|
||||
|
||||
---
|
||||
|
||||
## Decision flowchart
|
||||
|
||||
```
|
||||
Is it a fixed set of named constants?
|
||||
YES → StrEnum / IntEnum
|
||||
NO ↓
|
||||
Is it just branding a primitive (int, str, float)?
|
||||
YES → NewType("X", base)
|
||||
NO ↓
|
||||
Is it an interface / contract ("this thing can do X")?
|
||||
├─ Shape only, no shared code → Protocol
|
||||
└─ Shared method implementation needed → ABC
|
||||
NO ↓
|
||||
Does the data cross a trust boundary (user input, API, file, external DB)?
|
||||
YES → pydantic.BaseModel (frozen=True) — validates + serializes
|
||||
NO ↓
|
||||
Is it a dict shape needed for JSON compat / **kwargs typing?
|
||||
YES → TypedDict
|
||||
NO ↓
|
||||
Is it structured data with named fields?
|
||||
YES → @dataclass(frozen=True, slots=True)
|
||||
NO ↓
|
||||
Is it a tuple with positional semantics (x, y coords / DB row)?
|
||||
YES → NamedTuple
|
||||
NO → you probably don't need a new type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container reference
|
||||
|
||||
### @dataclass — internal value object
|
||||
|
||||
The default for structured data inside your codebase. Zero overhead, no framework coupling.
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import NewType
|
||||
|
||||
UserId = NewType("UserId", int)
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class User:
|
||||
id: UserId
|
||||
name: str
|
||||
email: str
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Point:
|
||||
x: float
|
||||
y: float
|
||||
```
|
||||
|
||||
Always `frozen=True, slots=True`. Mutable only when mutation is the documented purpose — opt out with `# noqa: MUTABLE_OK`.
|
||||
|
||||
### Pydantic BaseModel — trust boundary guardian
|
||||
|
||||
Use when data enters or leaves your system. Validates at construction, serializes to JSON, generates OpenAPI schema.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
email: EmailStr
|
||||
age: int
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
```
|
||||
|
||||
**The one rule**: data crosses a trust boundary → Pydantic. Everything else → dataclass.
|
||||
Never use Pydantic for internal-only data just because it's convenient. The validation cost is real.
|
||||
|
||||
### TypedDict — dict that knows its shape
|
||||
|
||||
Use when the value must stay a `dict` at runtime — JSON blobs, `**kwargs`, third-party APIs expecting dicts.
|
||||
|
||||
```python
|
||||
from typing import TypedDict, NotRequired
|
||||
|
||||
class Headers(TypedDict):
|
||||
content_type: str
|
||||
authorization: NotRequired[str]
|
||||
|
||||
def make_request(url: str, headers: Headers) -> None: ...
|
||||
|
||||
make_request("https://api.example.com", {"content_type": "application/json"})
|
||||
```
|
||||
|
||||
### Protocol — structural interface
|
||||
|
||||
"Anything that has method X" — no inheritance required.
|
||||
|
||||
```python
|
||||
from typing import Protocol
|
||||
|
||||
class Renderable(Protocol):
|
||||
def render(self) -> str: ...
|
||||
|
||||
class Saveable(Protocol):
|
||||
async def save(self) -> None: ...
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarkdownDoc:
|
||||
content: str
|
||||
def render(self) -> str:
|
||||
return self.content
|
||||
|
||||
def publish(doc: Renderable) -> None:
|
||||
print(doc.render()) # MarkdownDoc works — no inheritance needed
|
||||
```
|
||||
|
||||
Default to Protocol for interfaces. ABC only when you need shared method implementations.
|
||||
|
||||
### ABC — interface with shared code
|
||||
|
||||
Only when Protocol isn't enough.
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class BaseRepository(ABC):
|
||||
@abstractmethod
|
||||
async def get(self, id: int) -> Model | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def save(self, model: Model) -> None: ...
|
||||
|
||||
async def get_or_raise(self, id: int) -> Model:
|
||||
result = await self.get(id)
|
||||
if result is None:
|
||||
msg = f"{type(self).__name__}: id {id} not found"
|
||||
raise LookupError(msg)
|
||||
return result
|
||||
```
|
||||
|
||||
### NamedTuple — positional + named (rare)
|
||||
|
||||
Only when you need tuple protocol (unpacking, indexing).
|
||||
|
||||
```python
|
||||
from typing import NamedTuple
|
||||
|
||||
class Coordinate(NamedTuple):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
x, y = Coordinate(1.0, 2.0) # tuple unpacking
|
||||
```
|
||||
|
||||
99% of the time, `@dataclass(frozen=True, slots=True)` is better.
|
||||
|
||||
---
|
||||
|
||||
## Quick lookup
|
||||
|
||||
| Situation | Use | Why |
|
||||
|---|---|---|
|
||||
| User input, API request/response | `Pydantic BaseModel` | Validation, JSON schema, serialization |
|
||||
| DB row ↔ Python (ORM) | SQLAlchemy `Mapped[]` model | ORM integration, async session |
|
||||
| Internal value object | `@dataclass(frozen=True, slots=True)` | Zero overhead, no validation needed |
|
||||
| Multiple outcomes from function | Union of frozen dataclasses | Distinct types for `match` |
|
||||
| Dict shape for JSON / `**kwargs` | `TypedDict` | Stays a dict at runtime |
|
||||
| Fixed constants | `StrEnum` / `IntEnum` | Exhaustive match, no typos |
|
||||
| Distinct primitive | `NewType("X", int)` | Zero runtime cost, type-level only |
|
||||
| Contract / capability | `Protocol` | Structural typing, no inheritance |
|
||||
| Contract + shared impl | `ABC` | When Protocol isn't enough |
|
||||
|
||||
---
|
||||
|
||||
## Comparison matrix
|
||||
|
||||
| Feature | dataclass | Pydantic | TypedDict | Protocol | NamedTuple | NewType | Enum |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Validation | - | ✓ | - | - | - | - | - |
|
||||
| JSON serialization | manual | built-in | native dict | - | - | - | `.value` |
|
||||
| Immutable | frozen=True | frozen=True | - (dict) | N/A | always | N/A | always |
|
||||
| Runtime cost | ~zero | validation | zero | zero | ~zero | zero | ~zero |
|
||||
| `match` support | ✓ | ✓ | - | - | ✓ | - | ✓ |
|
||||
| `slots` support | ✓ | - | - | - | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## Parse, don't validate
|
||||
|
||||
Validate at the boundary. Inside the boundary, types are proof of validity.
|
||||
|
||||
```python
|
||||
# BAD — validate then pass raw data
|
||||
def process_email(email: str) -> None:
|
||||
if "@" not in email:
|
||||
raise ValueError("invalid email")
|
||||
# still a raw str everywhere downstream
|
||||
|
||||
# GOOD — parse into typed value at boundary
|
||||
from typing import NewType
|
||||
|
||||
Email = NewType("Email", str)
|
||||
|
||||
def parse_email(raw: str) -> Email:
|
||||
if "@" not in raw or "." not in raw.split("@")[1]:
|
||||
msg = f"invalid email: {raw}"
|
||||
raise ValueError(msg)
|
||||
return Email(raw.lower().strip())
|
||||
|
||||
# Downstream only sees Email, never raw str
|
||||
def send_welcome(email: Email) -> None: ...
|
||||
```
|
||||
|
||||
With Pydantic this happens automatically — `EmailStr` is already a parsed type. Once constructed, `.email` is always valid. No re-validation needed.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Python docs: [dataclasses](https://docs.python.org/3/library/dataclasses.html)
|
||||
- Pydantic v2: [docs.pydantic.dev](https://docs.pydantic.dev/latest/)
|
||||
- Python docs: [typing — Protocol](https://docs.python.org/3/library/typing.html#typing.Protocol)
|
||||
- Python docs: [typing — TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict)
|
||||
- Alexis King: [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Data Processing — Polars + DuckDB
|
||||
|
||||
## The rule
|
||||
|
||||
NEVER pandas. Polars (with numpy) plus DuckDB. Pandas is 10-50x slower, has weaker types, and the modern Python data ecosystem has moved on.
|
||||
|
||||
## Quick decision tree
|
||||
|
||||
| Operation | Use | Why |
|
||||
|---|---|---|
|
||||
| `.csv` / `.parquet` / `.json` direct query | DuckDB | Zero memory load, SQL ergonomics |
|
||||
| `.duckdb` file | DuckDB | Native format |
|
||||
| Filter (any size) | Polars | 128x faster than DuckDB for filtering |
|
||||
| Sort | Polars | 12x faster |
|
||||
| Multi-table join | DuckDB | 3x faster, more join types |
|
||||
| Heavy GROUP BY aggregation | DuckDB | 4x faster on large datasets |
|
||||
| Window function | Polars | 3-5x faster |
|
||||
| Pivot / melt / string ops | Polars | 2x faster |
|
||||
| Larger than RAM | Polars streaming or DuckDB out-of-core | Both handle OOM |
|
||||
| Mixed pipeline | Hybrid (zero-copy via Arrow) | Use each tool's strengths |
|
||||
|
||||
For the deep version (per-operation benchmarks, OOM strategies, full execution templates), load the **`data-scientist`** skill - it lives in this same skill set and is the source of truth for performance numbers.
|
||||
|
||||
## Standard imports
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import duckdb
|
||||
```
|
||||
|
||||
## DuckDB direct file query (zero memory load)
|
||||
|
||||
```python
|
||||
result = duckdb.sql("""
|
||||
SELECT category, SUM(amount) AS total
|
||||
FROM 'data.csv'
|
||||
WHERE date >= '2026-01-01'
|
||||
GROUP BY category
|
||||
ORDER BY total DESC
|
||||
""").pl() # zero-copy → Polars DataFrame
|
||||
```
|
||||
|
||||
`.pl()` returns Polars; `.df()` would return pandas - never use `.df()`.
|
||||
|
||||
## Polars lazy pipeline
|
||||
|
||||
```python
|
||||
result = (
|
||||
pl.scan_csv("data.csv") # lazy, no read yet
|
||||
.filter(pl.col("amount") > 1000)
|
||||
.filter(pl.col("status") == "active")
|
||||
.sort("amount", descending=True)
|
||||
.head(100)
|
||||
.collect() # execute optimised plan
|
||||
)
|
||||
```
|
||||
|
||||
`scan_*` over `read_*` for files; `lazy()` then `collect()` for in-memory frames. Polars optimises the entire plan before execution (predicate pushdown, projection pushdown, common subexpression elimination).
|
||||
|
||||
## Streaming for OOM data
|
||||
|
||||
```python
|
||||
result = (
|
||||
pl.scan_csv("huge.csv")
|
||||
.filter(pl.col("active"))
|
||||
.group_by("category")
|
||||
.agg([
|
||||
pl.len().alias("count"),
|
||||
pl.sum("amount").alias("total"),
|
||||
])
|
||||
.collect(streaming=True)
|
||||
)
|
||||
```
|
||||
|
||||
## Hybrid pipeline (most realistic shape)
|
||||
|
||||
```python
|
||||
# Phase 1: DuckDB for the join (3x faster)
|
||||
joined = duckdb.sql("""
|
||||
SELECT o.*, c.region, p.category
|
||||
FROM 'orders.parquet' o
|
||||
JOIN 'customers.parquet' c ON o.customer_id = c.id
|
||||
JOIN 'products.parquet' p ON o.product_id = p.id
|
||||
""").pl()
|
||||
|
||||
# Phase 2: Polars for filtering and transformation (128x + 2x faster)
|
||||
processed = (
|
||||
joined
|
||||
.filter(pl.col("amount") > 100)
|
||||
.with_columns([
|
||||
(pl.col("amount") * 1.1).alias("amount_with_tax"),
|
||||
])
|
||||
)
|
||||
|
||||
# Phase 3: DuckDB for final aggregation (4x faster) - register Polars frame by name
|
||||
duckdb.register("processed", processed)
|
||||
final = duckdb.sql("""
|
||||
SELECT region, category, SUM(amount_with_tax) AS revenue
|
||||
FROM processed
|
||||
GROUP BY region, category
|
||||
ORDER BY revenue DESC
|
||||
""").pl()
|
||||
```
|
||||
|
||||
## Type safety with Polars
|
||||
|
||||
Polars supports schema overrides at read time, and `.cast()` for explicit conversion. Avoid implicit coercion in hot paths.
|
||||
|
||||
```python
|
||||
schema = {"id": pl.Int64, "amount": pl.Float64, "date": pl.Date}
|
||||
df = pl.read_csv("data.csv", schema_overrides=schema)
|
||||
```
|
||||
|
||||
basedpyright understands `polars-stubs`, which ship with polars itself. No extra type stubs to install.
|
||||
|
||||
## Things you might miss from pandas (and how to do them in Polars)
|
||||
|
||||
| pandas | polars |
|
||||
|---|---|
|
||||
| `df.iloc[5]` | `df.row(5)` (named tuple) or `df[5]` (single-row frame) |
|
||||
| `df.loc[df["x"] > 5]` | `df.filter(pl.col("x") > 5)` |
|
||||
| `df["x"].apply(fn)` | `df["x"].map_elements(fn)` (slow path) or use native expressions |
|
||||
| `df.merge(...)` | `df.join(other, on="key")` |
|
||||
| `df.groupby(...).agg(...)` | `df.group_by(...).agg(...)` |
|
||||
| `pd.read_csv(...).dtypes` | `pl.read_csv(...).schema` |
|
||||
| `df.to_dict("records")` | `df.to_dicts()` |
|
||||
|
||||
## Sources
|
||||
|
||||
- Polars docs: <https://docs.pola.rs>
|
||||
- DuckDB Python API: <https://duckdb.org/docs/api/python/overview>
|
||||
- Cross-reference - this skill set's `data-scientist` skill (load it for the deep version)
|
||||
@@ -0,0 +1,218 @@
|
||||
# Error Handling
|
||||
|
||||
Typed errors, exhaustive matching, union returns, and resource safety.
|
||||
|
||||
---
|
||||
|
||||
## Typed errors — no bare strings
|
||||
|
||||
Error types carry structured data. Pattern matching works. Callers know exactly what can go wrong.
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import NewType
|
||||
|
||||
UserId = NewType("UserId", int)
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserNotFoundError(Exception):
|
||||
user_id: UserId
|
||||
|
||||
def __str__(self) -> str: # REQUIRED — see note below
|
||||
return f"user {self.user_id} not found"
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PermissionDeniedError(Exception):
|
||||
user_id: UserId
|
||||
required_role: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"user {self.user_id} needs role {self.required_role}"
|
||||
```
|
||||
|
||||
**`__str__` is mandatory** on dataclass exceptions. `@dataclass` replaces `Exception.__init__`, so `self.args` is always `()`. Without `__str__`, `str(e)` returns an empty string and logging/monitoring breaks.
|
||||
|
||||
```python
|
||||
# BAD
|
||||
raise ValueError("user not found")
|
||||
raise ValueError("permission denied")
|
||||
|
||||
# GOOD
|
||||
raise UserNotFoundError(user_id=uid)
|
||||
raise PermissionDeniedError(user_id=uid, required_role="admin")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Union returns — expected failures without exceptions
|
||||
|
||||
For failures that are **expected** (not found, validation error, permission denied), return a union instead of raising. Exceptions are for **unexpected** failures (network down, OOM, corrupted data).
|
||||
|
||||
### Define the outcome types
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class User:
|
||||
id: UserId
|
||||
name: str
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserNotFound:
|
||||
id: UserId
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PermissionDenied:
|
||||
id: UserId
|
||||
reason: str
|
||||
|
||||
type GetUserResult = User | UserNotFound | PermissionDenied
|
||||
```
|
||||
|
||||
### Handle exhaustively
|
||||
|
||||
```python
|
||||
from typing import assert_never
|
||||
|
||||
def handle_result(result: GetUserResult) -> str:
|
||||
match result:
|
||||
case User(name=name):
|
||||
return f"Found: {name}"
|
||||
case UserNotFound(id=uid):
|
||||
return f"No user with id {uid}"
|
||||
case PermissionDenied(reason=reason):
|
||||
return f"Denied: {reason}"
|
||||
case _ as unreachable:
|
||||
assert_never(unreachable)
|
||||
```
|
||||
|
||||
`assert_never` in the default case: if you add a new variant to `GetUserResult` without handling it here, the type checker errors. No silent fall-through.
|
||||
|
||||
### When to use which
|
||||
|
||||
**The heuristic**: caller is 1-2 levels away and MUST handle it → union return. Error should propagate up many layers to a boundary → exception.
|
||||
|
||||
| Scenario | Pattern | Why |
|
||||
|---|---|---|
|
||||
| Repository → service (caller handles it) | Union return (`User \| UserNotFound`) | Caller is right there, must handle both |
|
||||
| Validation at boundary (parsing input) | Exception (typed, with fields) | Propagates up to HTTP/CLI handler |
|
||||
| Infrastructure failure (network, OOM) | Exception | Can't handle locally, must propagate |
|
||||
| Service → service (deep internal) | Exception (typed) | Union boilerplate across many layers is worse than exceptions |
|
||||
| HTTP handler → response | Catch exceptions, convert to response | Boundary code catches and translates |
|
||||
|
||||
**Practical tradeoff**: union returns are safest (type checker forces handling) but create boilerplate when every caller in a chain must `match`. If the error would just propagate through 3+ layers unchanged, use a typed exception instead.
|
||||
|
||||
---
|
||||
|
||||
## Exhaustive match — every match needs a default
|
||||
|
||||
Every `match` statement ends with `case _: assert_never(x)`. No exceptions.
|
||||
|
||||
```python
|
||||
from enum import StrEnum
|
||||
from typing import assert_never
|
||||
|
||||
class Status(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
DELETED = "deleted"
|
||||
|
||||
def describe(status: Status) -> str:
|
||||
match status:
|
||||
case Status.PENDING:
|
||||
return "waiting"
|
||||
case Status.ACTIVE:
|
||||
return "live"
|
||||
case Status.DELETED:
|
||||
return "gone"
|
||||
case _ as unreachable:
|
||||
assert_never(unreachable)
|
||||
```
|
||||
|
||||
Add a new enum member? The type checker tells you every `match` that needs updating.
|
||||
|
||||
---
|
||||
|
||||
## Context managers — resource safety
|
||||
|
||||
If it has `.close()`, `.shutdown()`, `.disconnect()`, or `.release()`, wrap it in `with`.
|
||||
|
||||
```python
|
||||
# BAD
|
||||
f = open("data.txt")
|
||||
data = f.read()
|
||||
f.close() # forgotten? leaked
|
||||
|
||||
# GOOD
|
||||
from pathlib import Path
|
||||
|
||||
data = Path("data.txt").read_text()
|
||||
```
|
||||
|
||||
### Async resources
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def fetch_users() -> list[User]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("https://api.example.com/users")
|
||||
response.raise_for_status()
|
||||
return [User(**u) for u in response.json()]
|
||||
```
|
||||
|
||||
### Custom context manager
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
@asynccontextmanager
|
||||
async def managed_connection(url: str) -> AsyncIterator[Connection]:
|
||||
conn = await connect(url)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async with managed_connection("postgres://...") as conn:
|
||||
await conn.execute("SELECT 1")
|
||||
# conn is closed here, guaranteed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exception hierarchy — when you do raise
|
||||
|
||||
Keep exception hierarchies shallow and specific.
|
||||
|
||||
```python
|
||||
class AppError(Exception):
|
||||
"""Base for all application errors."""
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NotFoundError(AppError):
|
||||
entity: str
|
||||
id: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.entity} {self.id} not found"
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConflictError(AppError):
|
||||
entity: str
|
||||
field: str
|
||||
value: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.entity}.{self.field} = {self.value!r} already exists"
|
||||
```
|
||||
|
||||
Callers catch `AppError` at the boundary, or specific subtypes where they can do something useful.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Python docs: [typing — assert_never](https://docs.python.org/3/library/typing.html#typing.assert_never)
|
||||
- Python docs: [contextlib](https://docs.python.org/3/library/contextlib.html)
|
||||
- Python docs: [match statement](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement)
|
||||
@@ -0,0 +1,316 @@
|
||||
# FastAPI + SQLAlchemy 2.x async + Postgres + Pydantic v2
|
||||
|
||||
The canonical web API stack. Async end-to-end, type-safe end-to-end, OpenAPI-generated end-to-end.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
myapi/
|
||||
├── pyproject.toml
|
||||
├── alembic.ini
|
||||
├── migrations/
|
||||
│ └── env.py
|
||||
├── src/
|
||||
│ └── myapi/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app + lifespan
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── db.py # engine, session factory, dependency
|
||||
│ ├── models.py # SQLAlchemy declarative models
|
||||
│ ├── schemas.py # Pydantic request/response models
|
||||
│ └── routers/
|
||||
│ ├── __init__.py
|
||||
│ └── users.py
|
||||
└── tests/
|
||||
├── conftest.py
|
||||
└── test_users.py
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
uv add fastapi 'sqlalchemy[asyncio]>=2.0' asyncpg 'pydantic[email]>=2' pydantic-settings 'uvicorn[standard]' orjson
|
||||
uv add --dev httpx pytest alembic
|
||||
```
|
||||
|
||||
`orjson` is mandatory: set `default_response_class=ORJSONResponse` on the FastAPI app. Pydantic-typed responses bypass it (Pydantic v2's `model_dump_json` is already Rust-backed); raw `dict` / `list` returns are accelerated. For SSE / NDJSON streams, call `orjson.dumps(...)` per chunk inside `StreamingResponse`. See `orjson-stack.md` for the decision tree, flag reference, and benchmarks.
|
||||
|
||||
## Configuration (`config.py`)
|
||||
|
||||
```python
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field, PostgresDsn
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
|
||||
|
||||
database_url: PostgresDsn
|
||||
debug: bool = False
|
||||
cors_origins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg] # pydantic populates from env
|
||||
```
|
||||
|
||||
Wait — that comment violates the no-excuse rule. Use proper field defaults instead. Real version:
|
||||
|
||||
```python
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
|
||||
database_url: PostgresDsn
|
||||
debug: bool = False
|
||||
cors_origins: list[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
Construct via `Settings(_env_file=".env")` if needed in tests; in production it reads from env.
|
||||
|
||||
## Database (`db.py`)
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from myapi.config import get_settings
|
||||
|
||||
|
||||
def make_engine() -> AsyncEngine:
|
||||
settings = get_settings()
|
||||
return create_async_engine(
|
||||
str(settings.database_url),
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
_engine = make_engine()
|
||||
_SessionFactory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with _SessionFactory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
```
|
||||
|
||||
`expire_on_commit=False` is essential for FastAPI - otherwise attribute access after commit triggers an implicit refresh and errors out under async.
|
||||
|
||||
## Models (`models.py`)
|
||||
|
||||
```python
|
||||
from datetime import datetime, UTC
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
MappedAsDataclass,
|
||||
mapped_column,
|
||||
)
|
||||
|
||||
|
||||
class Base(MappedAsDataclass, DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, init=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
init=False,
|
||||
)
|
||||
```
|
||||
|
||||
`MappedAsDataclass` makes `User(email=..., name=...)` work as a real dataclass constructor. `init=False` excludes the auto-generated columns (`id`, `created_at`) from `__init__`.
|
||||
|
||||
## Schemas (`schemas.py`)
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True) # SQLAlchemy → Pydantic
|
||||
|
||||
id: int
|
||||
email: EmailStr
|
||||
name: str
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
Always have a separate `*Create` (input) and `*Read` (output) model. Never expose your ORM model as the API model.
|
||||
|
||||
## Routers (`routers/users.py`)
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from myapi.db import SessionDep
|
||||
from myapi.models import User
|
||||
from myapi.schemas import UserCreate, UserRead
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(payload: UserCreate, session: SessionDep) -> User:
|
||||
user = User(email=payload.email, name=payload.name)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(user_id: int, session: SessionDep) -> User:
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserRead])
|
||||
async def list_users(session: SessionDep, limit: int = 100) -> list[User]:
|
||||
result = await session.execute(select(User).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
```
|
||||
|
||||
## Application (`main.py`)
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from myapi.config import get_settings
|
||||
from myapi.routers import users
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
# Startup: warm up engine pool, run migrations check, etc.
|
||||
yield
|
||||
# Shutdown: close engine
|
||||
from myapi.db import _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="My API",
|
||||
debug=settings.debug,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.include_router(users.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
```
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
uv run uvicorn myapi.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
## Migrations (Alembic + async)
|
||||
|
||||
```bash
|
||||
uv run alembic init -t async migrations
|
||||
```
|
||||
|
||||
In `migrations/env.py` replace the `target_metadata` line:
|
||||
|
||||
```python
|
||||
from myapi.models import Base
|
||||
target_metadata = Base.metadata
|
||||
```
|
||||
|
||||
Set `sqlalchemy.url` in `alembic.ini` to your async URL or override via `env.py`:
|
||||
|
||||
```python
|
||||
from myapi.config import get_settings
|
||||
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
|
||||
```
|
||||
|
||||
Generate and apply:
|
||||
|
||||
```bash
|
||||
uv run alembic revision --autogenerate -m "create users"
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
## Tests (`tests/test_users.py`)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from myapi.main import app
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_get_user() -> None:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
create_response = await client.post(
|
||||
"/users",
|
||||
json={"email": "alice@example.com", "name": "Alice"},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
user_id = create_response.json()["id"]
|
||||
|
||||
get_response = await client.get(f"/users/{user_id}")
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["email"] == "alice@example.com"
|
||||
```
|
||||
|
||||
For database-backed tests, run a Postgres container in CI (`testcontainers-python` or `docker-compose`) and apply migrations against a test schema. SQLite-as-test-db breaks once you use Postgres-specific types (`JSONB`, `tsvector`, arrays).
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
| Pitfall | Fix |
|
||||
|---|---|
|
||||
| `MissingGreenlet` exception when accessing relationships after commit | `expire_on_commit=False` on the session factory |
|
||||
| Connection pool exhausted under load | Set `pool_size`, `max_overflow` in `create_async_engine` |
|
||||
| Pydantic v1 syntax (`from pydantic import ...; class X(BaseModel): class Config: orm_mode = True`) | v2 uses `model_config = ConfigDict(from_attributes=True)` |
|
||||
| Returning ORM objects without `response_model` | FastAPI serialises with `from_attributes=True` automatically; declare `response_model` so OpenAPI is correct |
|
||||
| `await session.execute(...)` returning Sequence | Wrap with `list(result.scalars().all())` to satisfy strict types |
|
||||
| `func.now()` returning naive datetime | Use `DateTime(timezone=True)` and `created_at: Mapped[datetime]` with `UTC`-aware default |
|
||||
|
||||
## Sources
|
||||
|
||||
- FastAPI: <https://fastapi.tiangolo.com>
|
||||
- SQLAlchemy 2.x async: <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html>
|
||||
- SQLAlchemy MappedAsDataclass: <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html>
|
||||
- asyncpg: <https://magicstack.github.io/asyncpg/current/>
|
||||
- Pydantic v2 migration: <https://docs.pydantic.dev/latest/migration/>
|
||||
- Alembic async: <https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic>
|
||||
@@ -0,0 +1,360 @@
|
||||
# httpx2 — Production Defaults
|
||||
|
||||
> **Source**: [pydantic/httpx2](https://github.com/pydantic/httpx2) — next-generation HTTP client for Python 3, continuation of HTTPX under Pydantic stewardship.
|
||||
>
|
||||
> **Rule**: Every network request MUST use `httpx2`. **ALL optimizations below are ON by default** — HTTP/2, brotli+zstd, tuned connection pool, fine-grained timeouts, transport retries, TCP_NODELAY. This is the baseline, not a stretch goal. A bare `httpx2.AsyncClient()` is a bug.
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation — all extras, always
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
dependencies = [
|
||||
"httpx2[http2,brotli,zstd]",
|
||||
]
|
||||
```
|
||||
|
||||
| Extra | What it enables | Why it's mandatory |
|
||||
|-------|----------------|--------------------|
|
||||
| `http2` | HTTP/2 multiplexing via `h2` | Single TCP connection handles concurrent requests; eliminates head-of-line blocking |
|
||||
| `brotli` | Brotli content decoding (`br`) | ~20% smaller payloads than gzip for text/JSON |
|
||||
| `zstd` | Zstandard content decoding | Faster decompression than brotli at similar ratios; stdlib in Python ≥ 3.14 |
|
||||
| `socks` | SOCKS5 proxy support via `socksio` | Install only if you route through SOCKS proxies |
|
||||
|
||||
All three core extras (`http2,brotli,zstd`) are non-negotiable. Omitting any is leaving performance on the table.
|
||||
|
||||
---
|
||||
|
||||
## 2. The canonical defaults — ALL ON
|
||||
|
||||
These are not "optimizations to consider". These are **the correct defaults** that every httpx2 client must use.
|
||||
|
||||
```python
|
||||
import socket
|
||||
import httpx2
|
||||
|
||||
# ── These are the STANDARD values. Use them verbatim. ──
|
||||
|
||||
LIMITS = httpx2.Limits(
|
||||
max_connections=200, # library default 100 is too conservative
|
||||
max_keepalive_connections=40, # library default 20 wastes reconnects
|
||||
keepalive_expiry=30.0, # library default 5s kills warm connections too fast
|
||||
)
|
||||
|
||||
TIMEOUT = httpx2.Timeout(
|
||||
connect=5.0, # TCP + TLS handshake budget
|
||||
read=30.0, # time to receive a response chunk
|
||||
write=10.0, # time to send a request chunk
|
||||
pool=10.0, # time to acquire a connection from pool
|
||||
)
|
||||
|
||||
SOCKET_OPTIONS: list[tuple[int, int, int]] = [
|
||||
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), # disable Nagle — no 40ms delay
|
||||
]
|
||||
```
|
||||
|
||||
### Why each knob is set this way
|
||||
|
||||
| Setting | Library default | Our default | Why |
|
||||
|---------|----------------|-------------|-----|
|
||||
| `http2` | `False` | **`True`** | HTTP/2 multiplexing is strictly superior for any modern API |
|
||||
| `max_connections` | `100` | `200` | Headroom for fan-out; prevents pool exhaustion under load |
|
||||
| `max_keepalive_connections` | `20` | `40` | Keeps warm connections alive; fewer TLS handshakes |
|
||||
| `keepalive_expiry` | `5.0s` | `30.0s` | 5s is too aggressive — kills connections between burst requests |
|
||||
| `Timeout(5.0)` uniform | `5.0` all | Split | Uniform 5s is too tight for reads, too loose for connects |
|
||||
| `read` timeout | `5.0` | `30.0` | Slow APIs and streaming need breathing room |
|
||||
| `pool` timeout | `5.0` | `10.0` | Explicit — hitting this means `max_connections` needs raising |
|
||||
| `TCP_NODELAY` | off | **on** | Eliminates Nagle's 40ms coalescing delay for small payloads |
|
||||
| `retries` | `0` | `3` | Retries on `ConnectError`/`ConnectTimeout` only — safe and resilient |
|
||||
| `follow_redirects` | `False` | **`True`** | Most APIs redirect; failing on 3xx is wrong default behavior |
|
||||
|
||||
---
|
||||
|
||||
## 3. Factory functions — the ONE correct way to create clients
|
||||
|
||||
Copy this into your project. This is the canonical pattern.
|
||||
|
||||
```python
|
||||
"""httpx2 client factory. Always use create_client() / create_async_client()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import typing
|
||||
|
||||
import httpx2
|
||||
|
||||
_LIMITS = httpx2.Limits(
|
||||
max_connections=200,
|
||||
max_keepalive_connections=40,
|
||||
keepalive_expiry=30.0,
|
||||
)
|
||||
|
||||
_TIMEOUT = httpx2.Timeout(
|
||||
connect=5.0,
|
||||
read=30.0,
|
||||
write=10.0,
|
||||
pool=10.0,
|
||||
)
|
||||
|
||||
_SOCKET_OPTIONS: list[tuple[int, int, int]] = [
|
||||
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
|
||||
]
|
||||
|
||||
|
||||
def create_async_client(
|
||||
*,
|
||||
base_url: str = "",
|
||||
http2: bool = True,
|
||||
retries: int = 3,
|
||||
limits: httpx2.Limits = _LIMITS,
|
||||
timeout: httpx2.Timeout = _TIMEOUT,
|
||||
headers: dict[str, str] | None = None,
|
||||
event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
|
||||
**kwargs: typing.Any,
|
||||
) -> httpx2.AsyncClient:
|
||||
transport = httpx2.AsyncHTTPTransport(
|
||||
http2=http2,
|
||||
retries=retries,
|
||||
limits=limits,
|
||||
socket_options=_SOCKET_OPTIONS,
|
||||
)
|
||||
return httpx2.AsyncClient(
|
||||
transport=transport,
|
||||
timeout=timeout,
|
||||
base_url=base_url,
|
||||
headers=headers or {},
|
||||
event_hooks=event_hooks or {},
|
||||
follow_redirects=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def create_client(
|
||||
*,
|
||||
base_url: str = "",
|
||||
http2: bool = True,
|
||||
retries: int = 3,
|
||||
limits: httpx2.Limits = _LIMITS,
|
||||
timeout: httpx2.Timeout = _TIMEOUT,
|
||||
headers: dict[str, str] | None = None,
|
||||
event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
|
||||
**kwargs: typing.Any,
|
||||
) -> httpx2.Client:
|
||||
transport = httpx2.HTTPTransport(
|
||||
http2=http2,
|
||||
retries=retries,
|
||||
limits=limits,
|
||||
socket_options=_SOCKET_OPTIONS,
|
||||
)
|
||||
return httpx2.Client(
|
||||
transport=transport,
|
||||
timeout=timeout,
|
||||
base_url=base_url,
|
||||
headers=headers or {},
|
||||
event_hooks=event_hooks or {},
|
||||
follow_redirects=True,
|
||||
**kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```python
|
||||
# Async — the common case
|
||||
async with create_async_client(base_url="https://api.example.com") as client:
|
||||
r = await client.get("/users")
|
||||
|
||||
# Sync
|
||||
with create_client() as client:
|
||||
r = client.get("https://api.example.com/health")
|
||||
```
|
||||
|
||||
**If you are NOT using this factory pattern, you are doing it wrong.** A bare `httpx2.AsyncClient()` leaves HTTP/2 off, retries off, TCP_NODELAY off, keepalive too short, and timeouts too uniform.
|
||||
|
||||
---
|
||||
|
||||
## 4. Special case overrides
|
||||
|
||||
The factory defaults cover 95% of use cases. Override only when you have a specific reason:
|
||||
|
||||
| Scenario | Override |
|
||||
|----------|----------|
|
||||
| LLM streaming endpoints | `timeout=httpx2.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)` — no read timeout on streaming |
|
||||
| Single-host API with low concurrency | `limits=httpx2.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=60.0)` |
|
||||
| Ephemeral short-lived requests | `keepalive_expiry=5.0` — don't hold connections |
|
||||
| Unix domain sockets | `httpx2.AsyncHTTPTransport(uds="/path/to/socket", ...)` |
|
||||
| mTLS / client certs | Pass `verify=ssl_ctx` with `ctx.load_cert_chain(certfile=...)` |
|
||||
| SOCKS proxy | `httpx2[socks]`, `proxy="socks5://..."` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Event hooks — always wire observability
|
||||
|
||||
This is not optional. Every production client should log requests.
|
||||
|
||||
```python
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def log_request(request: httpx2.Request) -> None:
|
||||
request.extensions["request_start"] = time.perf_counter()
|
||||
|
||||
async def log_response(response: httpx2.Response) -> None:
|
||||
start = response.request.extensions.get("request_start", 0)
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"HTTP %s %s → %d (%.3fs, %s)",
|
||||
response.request.method,
|
||||
response.request.url,
|
||||
response.status_code,
|
||||
elapsed,
|
||||
response.http_version,
|
||||
)
|
||||
|
||||
# Sync versions for Client
|
||||
def log_request_sync(request: httpx2.Request) -> None:
|
||||
request.extensions["request_start"] = time.perf_counter()
|
||||
|
||||
def log_response_sync(response: httpx2.Response) -> None:
|
||||
start = response.request.extensions.get("request_start", 0)
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"HTTP %s %s → %d (%.3fs, %s)",
|
||||
response.request.method,
|
||||
response.request.url,
|
||||
response.status_code,
|
||||
elapsed,
|
||||
response.http_version,
|
||||
)
|
||||
```
|
||||
|
||||
For auto `raise_for_status()`:
|
||||
|
||||
```python
|
||||
async def raise_on_error(response: httpx2.Response) -> None:
|
||||
response.raise_for_status()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification script — confirm your setup is fully optimized
|
||||
|
||||
Run this against your target endpoint to **verify** (not decide) that all optimizations are active:
|
||||
|
||||
```python
|
||||
"""Verify httpx2 is fully optimized against a target endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
import anyio
|
||||
import httpx2
|
||||
|
||||
|
||||
TARGET_URL = "https://api.example.com/health"
|
||||
ITERATIONS = 30
|
||||
|
||||
|
||||
async def bench(label: str, client: httpx2.AsyncClient, url: str, n: int) -> float:
|
||||
for _ in range(3): # warmup
|
||||
await client.get(url)
|
||||
start = time.perf_counter()
|
||||
for _ in range(n):
|
||||
r = await client.get(url)
|
||||
assert r.status_code == 200
|
||||
elapsed = time.perf_counter() - start
|
||||
avg_ms = (elapsed / n) * 1000
|
||||
print(f" {label}: {avg_ms:.1f}ms avg ({n} reqs in {elapsed:.2f}s)")
|
||||
return avg_ms
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
results: dict[str, float] = {}
|
||||
|
||||
# BAD: bare defaults (this is what we're proving is worse)
|
||||
async with httpx2.AsyncClient() as c:
|
||||
results["BAD-bare-defaults"] = await bench("BAD-bare-defaults", c, TARGET_URL, ITERATIONS)
|
||||
|
||||
# GOOD: full production defaults (this is what we always use)
|
||||
limits = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
|
||||
timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
|
||||
transport = httpx2.AsyncHTTPTransport(
|
||||
http2=True, retries=3, limits=limits,
|
||||
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
|
||||
)
|
||||
async with httpx2.AsyncClient(transport=transport, timeout=timeout, follow_redirects=True) as c:
|
||||
results["GOOD-full-production"] = await bench("GOOD-full-production", c, TARGET_URL, ITERATIONS)
|
||||
|
||||
print("\n--- Proof ---")
|
||||
baseline = results["BAD-bare-defaults"]
|
||||
for label, avg in results.items():
|
||||
delta = ((avg - baseline) / baseline) * 100
|
||||
print(f" {label}: {avg:.1f}ms ({delta:+.1f}% vs bare)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick reference — all knobs
|
||||
|
||||
### `httpx2.AsyncClient` / `httpx2.Client`
|
||||
|
||||
| Parameter | Type | Library Default | **Our Default** |
|
||||
|-----------|------|-----------------|-----------------|
|
||||
| `http1` | `bool` | `True` | `True` |
|
||||
| `http2` | `bool` | `False` | **`True`** |
|
||||
| `verify` | `ssl.SSLContext \| str \| bool` | `True` | `True` |
|
||||
| `cert` | `CertTypes \| None` | `None` | `None` |
|
||||
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
|
||||
| `mounts` | `dict[str, Transport]` | `None` | `None` |
|
||||
| `timeout` | `Timeout \| float \| None` | `Timeout(5.0)` | **Split: 5/30/10/10** |
|
||||
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
|
||||
| `follow_redirects` | `bool` | `False` | **`True`** |
|
||||
| `max_redirects` | `int` | `20` | `20` |
|
||||
| `event_hooks` | `dict` | `{}` | **Wire logging** |
|
||||
| `base_url` | `str` | `""` | Set for single-API clients |
|
||||
| `trust_env` | `bool` | `True` | `True` |
|
||||
| `default_encoding` | `str \| Callable` | `"utf-8"` | `"utf-8"` |
|
||||
|
||||
### `httpx2.AsyncHTTPTransport` / `httpx2.HTTPTransport`
|
||||
|
||||
| Parameter | Type | Library Default | **Our Default** |
|
||||
|-----------|------|-----------------|-----------------|
|
||||
| `http1` | `bool` | `True` | `True` |
|
||||
| `http2` | `bool` | `False` | **`True`** |
|
||||
| `retries` | `int` | `0` | **`3`** |
|
||||
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
|
||||
| `uds` | `str \| None` | `None` | `None` |
|
||||
| `local_address` | `str \| None` | `None` | `None` |
|
||||
| `socket_options` | `Iterable[SOCKET_OPTION]` | `None` | **`[TCP_NODELAY]`** |
|
||||
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
|
||||
|
||||
### `httpx2.Timeout`
|
||||
|
||||
| Parameter | Library Default | **Our Default** |
|
||||
|-----------|-----------------|-----------------|
|
||||
| `connect` | `5.0` | `5.0` |
|
||||
| `read` | `5.0` | **`30.0`** |
|
||||
| `write` | `5.0` | **`10.0`** |
|
||||
| `pool` | `5.0` | **`10.0`** |
|
||||
|
||||
### `httpx2.Limits`
|
||||
|
||||
| Parameter | Library Default | **Our Default** |
|
||||
|-----------|-----------------|-----------------|
|
||||
| `max_connections` | `100` | **`200`** |
|
||||
| `max_keepalive_connections` | `20` | **`40`** |
|
||||
| `keepalive_expiry` | `5.0` | **`30.0`** |
|
||||
|
||||
### Async backend (httpcore2)
|
||||
|
||||
httpcore2 uses `anyio` by default (works with both asyncio and trio). No extra config needed if you're already on the anyio stack. For trio, install `httpcore2[trio]`.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Library Defaults — Decision Tree
|
||||
|
||||
For each domain, the canonical 2026 choice, why, and the canonical usage snippet. The skill enforces these unless the project's `pyproject.toml` explicitly says otherwise.
|
||||
|
||||
## CLI — typer
|
||||
|
||||
`typer` builds a CLI from type-annotated function signatures. argparse needs 5x the code; click ignores type annotations; fire is magic that breaks at scale.
|
||||
|
||||
```python
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def greet(name: str, count: int = 1, shout: bool = False) -> None:
|
||||
"""Print a greeting `count` times."""
|
||||
message = f"Hello, {name}!" if not shout else f"HELLO, {name.upper()}!"
|
||||
for _ in range(count):
|
||||
rprint(message)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
```
|
||||
|
||||
For a single-function script, `typer.run(main)` skips the `Typer()` boilerplate. Subcommands use `@app.command()`.
|
||||
|
||||
## Terminal output — rich
|
||||
|
||||
`rich` produces tables, progress bars, syntax highlighting, traceback rendering. Use it for any structured output. Plain `print` is acceptable for non-interactive log lines (and even those are usually better via `rich.console.Console(stderr=True).log(...)`).
|
||||
|
||||
```python
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
table = Table(title="Users")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name", style="magenta")
|
||||
table.add_row("1", "Alice")
|
||||
console.print(table)
|
||||
|
||||
# Rich tracebacks (call once at process start)
|
||||
from rich.traceback import install
|
||||
install(show_locals=True)
|
||||
```
|
||||
|
||||
## HTTP client — [httpx2](https://github.com/pydantic/httpx2)
|
||||
|
||||
Next-generation HTTP client under Pydantic stewardship. Sync and async in one library, HTTP/2 native, brotli + zstd content decoding, real type stubs. Replaces `requests` (sync only), `aiohttp` (async only), and the original `httpx`.
|
||||
|
||||
**Install**: `httpx2[http2,brotli,zstd]` — always include all three extras, no exceptions.
|
||||
|
||||
**A bare `httpx2.AsyncClient()` / `httpx2.Client()` is a bug.** Always use the factory pattern from `references/httpx2-optimization.md` with ALL optimizations enabled by default:
|
||||
|
||||
```python
|
||||
import socket
|
||||
import httpx2
|
||||
|
||||
# ── Production defaults — ALL ON, always. ──
|
||||
_LIMITS = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
|
||||
_TIMEOUT = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
|
||||
_SOCKET_OPTS: list[tuple[int, int, int]] = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]
|
||||
|
||||
# Async (the common case)
|
||||
transport = httpx2.AsyncHTTPTransport(http2=True, retries=3, limits=_LIMITS, socket_options=_SOCKET_OPTS)
|
||||
async with httpx2.AsyncClient(transport=transport, timeout=_TIMEOUT, follow_redirects=True) as client:
|
||||
response = await client.get("https://api.example.com/users")
|
||||
response.raise_for_status()
|
||||
users = response.json()
|
||||
|
||||
# Sync
|
||||
transport = httpx2.HTTPTransport(http2=True, retries=3, limits=_LIMITS, socket_options=_SOCKET_OPTS)
|
||||
with httpx2.Client(transport=transport, timeout=_TIMEOUT, follow_redirects=True) as client:
|
||||
response = client.get("https://api.example.com/users")
|
||||
response.raise_for_status()
|
||||
users = response.json()
|
||||
```
|
||||
|
||||
See `references/httpx2-optimization.md` for the full factory functions (`create_client()` / `create_async_client()`), event hooks, and the rationale behind every setting. **Load that reference whenever you write ANY network code.**
|
||||
|
||||
## JSON — stdlib `json` (default) or `orjson` (hot paths)
|
||||
|
||||
Stdlib `json` is fine for cold paths and configs. **Reach for `orjson` when JSON is in the hot path** — cache layers, queue payloads, streaming responses, structured logs, FastAPI endpoints returning raw `dict` / `list`.
|
||||
|
||||
```python
|
||||
import orjson
|
||||
|
||||
# orjson.dumps returns bytes, not str
|
||||
raw: bytes = orjson.dumps(
|
||||
payload,
|
||||
option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z | orjson.OPT_SERIALIZE_DATACLASS,
|
||||
)
|
||||
```
|
||||
|
||||
**Critical 2026 fact**: with Pydantic v2, `model.model_dump_json()` is backed by pydantic-core (Rust) and is faster than `orjson + default=` bridge for Pydantic-shaped responses. **Use `model_dump_json()` for Pydantic; orjson for everything else.**
|
||||
|
||||
For FastAPI: `app = FastAPI(default_response_class=ORJSONResponse)`. Pydantic-typed responses bypass it (and that's correct — Pydantic's path is faster). Raw `dict`/`list` returns go through orjson.
|
||||
|
||||
See `references/orjson-stack.md` for the full decision tree, option flag reference, FastAPI integration, Redis/queue/logging patterns, and the `model_dump_json()` vs orjson benchmark.
|
||||
|
||||
## Validation — pydantic v2
|
||||
|
||||
Pydantic v2's core is in Rust (~10x faster than v1). It is the de-facto boundary validator. Use it for:
|
||||
|
||||
- HTTP request/response models (FastAPI uses pydantic natively)
|
||||
- Config files (env vars via `pydantic-settings`)
|
||||
- Anything entering the program from outside
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||
|
||||
class User(BaseModel):
|
||||
id: int = Field(ge=1)
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
age: int | None = Field(default=None, ge=0, le=150)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_no_digits(cls, v: str) -> str:
|
||||
if any(c.isdigit() for c in v):
|
||||
raise ValueError("name cannot contain digits")
|
||||
return v
|
||||
|
||||
# Inside the program, use the validated instance with confidence
|
||||
user = User.model_validate({"id": 1, "email": "a@b.com", "name": "Alice"})
|
||||
print(user.model_dump_json(indent=2))
|
||||
```
|
||||
|
||||
`@dataclass` is fine for purely internal records (no validation needed). For anything crossing a process boundary, use Pydantic.
|
||||
|
||||
## Async — anyio
|
||||
|
||||
Full reference: [async-anyio.md](async-anyio.md). The summary:
|
||||
|
||||
```python
|
||||
import anyio
|
||||
|
||||
async def fetch(url: str) -> str:
|
||||
await anyio.sleep(0.1)
|
||||
return url
|
||||
|
||||
async def main() -> None:
|
||||
async with anyio.create_task_group() as tg:
|
||||
for url in ["a", "b", "c"]:
|
||||
tg.start_soon(fetch, url)
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
Never `import asyncio` directly. The third-party libraries you call are free to use asyncio internally.
|
||||
|
||||
## Web framework — fastapi
|
||||
|
||||
Type-hint-driven HTTP framework. Pydantic models become OpenAPI schemas automatically.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
|
||||
@app.post("/users", response_model=User)
|
||||
async def create_user(payload: CreateUser) -> User:
|
||||
return User(id=1, **payload.model_dump())
|
||||
```
|
||||
|
||||
Full stack with database: [fastapi-stack.md](fastapi-stack.md).
|
||||
|
||||
## ORM — sqlalchemy 2.x async
|
||||
|
||||
SQLAlchemy 2.x finally has a real async API. Use the modern declarative `MappedAsDataclass` style with type annotations.
|
||||
|
||||
```python
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, MappedAsDataclass
|
||||
|
||||
class Base(MappedAsDataclass, DeclarativeBase):
|
||||
pass
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[int] = mapped_column(primary_key=True, init=False)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
|
||||
engine = create_async_engine("postgresql+asyncpg://localhost/myapp")
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
```
|
||||
|
||||
Full pattern with FastAPI integration: [fastapi-stack.md](fastapi-stack.md).
|
||||
|
||||
## Database — postgres + asyncpg
|
||||
|
||||
For new applications, default to Postgres. SQLite for tests is fine; SQLite for production is not.
|
||||
|
||||
asyncpg is the fastest Python Postgres driver, native to SQLAlchemy 2.x async, native to FastAPI's lifespan model. URL: `postgresql+asyncpg://user:pass@host:5432/db`.
|
||||
|
||||
For migrations, use Alembic with `[alembic.context]` configured to use the async engine. Single-step:
|
||||
|
||||
```bash
|
||||
uv add alembic
|
||||
uv run alembic init -t async migrations
|
||||
```
|
||||
|
||||
## TUI — textual
|
||||
|
||||
Textual builds rich, mouse-aware, mobile-style TUIs on the rich rendering engine. See [textual-tui.md](textual-tui.md).
|
||||
|
||||
## AI agents — pydantic-ai
|
||||
|
||||
The agent framework from the Pydantic team. Type-strict, structured outputs are first-class, model-agnostic. See [pydantic-ai.md](pydantic-ai.md).
|
||||
|
||||
## DataFrames — polars + numpy
|
||||
|
||||
Polars is 10-50x faster than pandas, has a real type system, and supports lazy evaluation. Numpy stays in the toolbox for arrays. See [data-processing.md](data-processing.md).
|
||||
|
||||
## OLAP / SQL — duckdb
|
||||
|
||||
DuckDB is the SQL engine for analytical workloads. Query CSV/Parquet/JSON files directly without loading into memory; perform joins and aggregations 3-4x faster than Polars; zero-copy interchange with Polars via Arrow. See [data-processing.md](data-processing.md).
|
||||
|
||||
## Tests — pytest
|
||||
|
||||
Plain `unittest` is fine for stdlib; everything else uses pytest. Conventions:
|
||||
|
||||
- File names `test_*.py`, function names `test_*`.
|
||||
- Fixtures via `@pytest.fixture`. Async fixtures are anyio-aware (`@pytest.fixture` on an async function works under `pytest-anyio` which is bundled with anyio).
|
||||
- Parametrise with `@pytest.mark.parametrize`.
|
||||
- Mark async tests with `@pytest.mark.anyio` (provided by anyio's pytest plugin).
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import anyio
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user() -> dict[str, str]:
|
||||
return {"name": "Alice", "email": "a@b.com"}
|
||||
|
||||
@pytest.mark.parametrize("count,expected", [(1, "Hello"), (2, "Hello, Hello")])
|
||||
def test_greet(count: int, expected: str) -> None:
|
||||
result = ", ".join(["Hello"] * count)
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_fetch() -> None:
|
||||
await anyio.sleep(0)
|
||||
assert True
|
||||
```
|
||||
|
||||
`pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = ["-ra", "--strict-config", "--strict-markers"]
|
||||
```
|
||||
|
||||
## Settings / config — pydantic-settings
|
||||
|
||||
Loads env vars and `.env` files into a Pydantic model. Replaces ad-hoc `os.environ.get(...)` everywhere.
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPP_")
|
||||
|
||||
database_url: str
|
||||
api_key: str = Field(min_length=1)
|
||||
debug: bool = False
|
||||
|
||||
settings = Settings() # loads at import time; raises if any required var is missing
|
||||
```
|
||||
|
||||
## Logging — stdlib logging + rich handler
|
||||
|
||||
Stdlib `logging` is fine; it gets a face-lift from `rich.logging.RichHandler`.
|
||||
|
||||
```python
|
||||
import logging
|
||||
from rich.logging import RichHandler
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
log.info("ready")
|
||||
```
|
||||
|
||||
For structured logging in production, swap to `structlog` (separate dep). Don't roll your own.
|
||||
@@ -0,0 +1,268 @@
|
||||
# One-liner Scripts (PEP 723 + uv)
|
||||
|
||||
Self-contained Python scripts with declared dependencies, run with no environment setup. The combination eliminates the historical reason to write small tools in Go or Bash.
|
||||
|
||||
**Rule: EVERY `.py` script — even throwaway — MUST use PEP 723 inline metadata with the usage comment block.** No venv, no requirements.txt, no setup.py. The script IS the environment spec.
|
||||
|
||||
## The two patterns
|
||||
|
||||
### Pattern 1: inline `uv run` invocation
|
||||
|
||||
```bash
|
||||
uv run --with httpx2 --with rich python -c "
|
||||
import httpx2
|
||||
from rich import print
|
||||
print(httpx2.get('https://api.github.com').json())
|
||||
"
|
||||
```
|
||||
|
||||
Use for terminal one-shots that you don't want to save. `--with PKG` may be repeated.
|
||||
|
||||
### Pattern 2: PEP 723 script with shebang (THE CANONICAL PATTERN)
|
||||
|
||||
A regular `.py` file with metadata in a comment block. uv reads the metadata, materialises a disposable venv (cached), and runs the script.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "httpx2[http2,brotli,zstd]",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run directly (no venv, no pip install needed):
|
||||
# uv run my_script.py
|
||||
# 3. Or make executable and run:
|
||||
# chmod +x my_script.py && ./my_script.py
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx2
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with httpx2.Client(http2=True, follow_redirects=True) as client:
|
||||
resp = client.get("https://api.github.com")
|
||||
resp.raise_for_status()
|
||||
rprint(resp.json())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### Mandatory elements
|
||||
|
||||
Every PEP 723 script MUST include these, in order:
|
||||
|
||||
1. **Shebang**: `#!/usr/bin/env -S uv run --script`
|
||||
2. **PEP 723 metadata block**: `# /// script` ... `# ///` with `requires-python` and `dependencies`
|
||||
3. **Usage comment block**: How to install uv + how to run the script. Copy the template above verbatim.
|
||||
4. **`from __future__ import annotations`**: Always first import.
|
||||
5. **`if __name__ == "__main__": main()`**: Entry point guard.
|
||||
|
||||
### The usage comment block (NON-NEGOTIABLE)
|
||||
|
||||
```python
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run directly (no venv, no pip install needed):
|
||||
# uv run <SCRIPT_NAME>.py [ARGS]
|
||||
# 3. Or make executable and run:
|
||||
# chmod +x <SCRIPT_NAME>.py && ./<SCRIPT_NAME>.py
|
||||
# ──────────────────
|
||||
```
|
||||
|
||||
Replace `<SCRIPT_NAME>` with the actual filename. Add argument descriptions if the script takes CLI args. This block goes immediately after the `# ///` closing line, before any imports.
|
||||
|
||||
**Why mandatory**: Anyone who receives this script — colleague, CI, future you — must know how to run it without reading docs. The comment IS the docs.
|
||||
|
||||
## Template generator
|
||||
|
||||
Use `scripts/new-script.py` to scaffold a new PEP 723 script with all boilerplate pre-filled:
|
||||
|
||||
```bash
|
||||
# Generate to temp directory (default)
|
||||
uv run scripts/new-script.py my_tool
|
||||
|
||||
# Generate to specific path
|
||||
uv run scripts/new-script.py my_tool --output ./scripts/my_tool.py
|
||||
|
||||
# With extra dependencies
|
||||
uv run scripts/new-script.py my_tool --deps "polars" "duckdb" "rich"
|
||||
```
|
||||
|
||||
## Common dependency sets
|
||||
|
||||
| Use case | Dependencies line |
|
||||
|---|---|
|
||||
| API client | `"httpx2[http2,brotli,zstd]"` |
|
||||
| Data processing | `"polars"`, `"duckdb"` |
|
||||
| CLI tool | `"typer"`, `"rich"` |
|
||||
| Web scraping | `"httpx2[http2,brotli,zstd]"`, `"selectolax"` |
|
||||
| File watcher | `"watchfiles"` |
|
||||
| JSON pretty | `"rich"` |
|
||||
| AI / LLM | `"pydantic-ai"`, `"httpx2[http2,brotli,zstd]"` |
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Fetch + print JSON
|
||||
|
||||
```python
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "httpx2[http2,brotli,zstd]",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run: uv run fetch_json.py https://api.github.com/repos/pydantic/httpx2
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import httpx2
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
def main() -> None:
|
||||
url = sys.argv[1] if len(sys.argv) > 1 else "https://api.github.com"
|
||||
with httpx2.Client(http2=True, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
rprint(resp.json())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### CSV → Parquet conversion
|
||||
|
||||
```python
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "polars",
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run: uv run csv2parquet.py input.csv output.parquet
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
def main(input_path: Path, output_path: Path | None = None) -> None:
|
||||
"""Convert CSV to Parquet."""
|
||||
out = output_path or input_path.with_suffix(".parquet")
|
||||
df = pl.read_csv(input_path)
|
||||
df.write_parquet(out)
|
||||
rprint(f"[green]✓[/green] {input_path} → {out} ({len(df)} rows)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
```
|
||||
|
||||
### Quick benchmark
|
||||
|
||||
```python
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "httpx2[http2,brotli,zstd]",
|
||||
# "rich",
|
||||
# "anyio",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run: uv run bench.py https://api.example.com/health 50
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
import anyio
|
||||
import httpx2
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
url = sys.argv[1] if len(sys.argv) > 1 else "https://api.github.com"
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
|
||||
limits = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
|
||||
timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
|
||||
transport = httpx2.AsyncHTTPTransport(
|
||||
http2=True, retries=3, limits=limits,
|
||||
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
|
||||
)
|
||||
|
||||
async with httpx2.AsyncClient(transport=transport, timeout=timeout, follow_redirects=True) as client:
|
||||
# warmup
|
||||
for _ in range(3):
|
||||
await client.get(url)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(n):
|
||||
r = await client.get(url)
|
||||
assert r.status_code == 200
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
avg_ms = (elapsed / n) * 1000
|
||||
rprint(f"[bold]{url}[/bold]: {avg_ms:.1f}ms avg over {n} requests ({elapsed:.2f}s total, {r.http_version})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| ❌ Don't | ✅ Do |
|
||||
|---|---|
|
||||
| `pip install httpx2 && python script.py` | `uv run script.py` |
|
||||
| `requirements.txt` alongside script | PEP 723 inline metadata |
|
||||
| `python -m venv .venv && ...` | `uv run --script` handles it |
|
||||
| Script without usage comment | Always include the "How to run" block |
|
||||
| `import asyncio; asyncio.run(main())` | `import anyio; anyio.run(main)` |
|
||||
| Bare `httpx2.AsyncClient()` | Full production defaults (see `references/httpx2-optimization.md`) |
|
||||
|
||||
## Sources
|
||||
|
||||
- PEP 723 - Inline script metadata: <https://peps.python.org/pep-0723/>
|
||||
- uv `run --script` docs: <https://docs.astral.sh/uv/guides/scripts/>
|
||||
- Original article: <https://www.cottongeeks.com/articles/2025-06-24-fun-with-uv-and-pep-723>
|
||||
- Simon Willison on one-shot Python tools: <https://simonwillison.net/2024/Dec/19/one-shot-python-tools/>
|
||||
@@ -0,0 +1,378 @@
|
||||
# orjson — When to Use, How to Integrate
|
||||
|
||||
`orjson` is the fastest JSON library on PyPI — written in Rust, 6–11× faster than stdlib `json` on serialization, 1.5–4× faster on deserialization. It also supports types the stdlib refuses to serialize: `datetime`, `date`, `UUID`, `numpy` arrays, `dataclass`, Pydantic models (via a small bridge).
|
||||
|
||||
This document covers the production patterns. **Not every project needs orjson.** The decision tree is in §1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision tree — should you adopt orjson?
|
||||
|
||||
```
|
||||
Are you serializing/deserializing JSON in a hot path?
|
||||
├─ NO → stdlib `json` is fine. Stop here.
|
||||
└─ YES ↓
|
||||
|
||||
Is the project FastAPI?
|
||||
├─ YES ↓
|
||||
│
|
||||
│ Is your response body fully described by a Pydantic v2 model?
|
||||
│ ├─ YES → Use FastAPI's default JSON response (uses Pydantic's
|
||||
│ │ Rust-backed serializer; orjson saves nothing in this path).
|
||||
│ │ Adopt orjson only for *non-Pydantic* responses below.
|
||||
│ └─ NO → Use `ORJSONResponse` for endpoints that return dicts,
|
||||
│ lists, or arbitrary structures.
|
||||
│
|
||||
└─ NOT FastAPI ↓
|
||||
|
||||
Are you serializing Pydantic v2 models repeatedly?
|
||||
├─ YES → Use `model.model_dump_json()` directly — backed by pydantic-core
|
||||
│ (Rust), within ~10% of orjson on the same payload, and respects
|
||||
│ every Pydantic feature (computed fields, aliases, validators).
|
||||
└─ NO ↓
|
||||
|
||||
Are you serializing dicts / lists / dataclasses / datetime / UUID?
|
||||
├─ YES → orjson is the right answer.
|
||||
└─ NO → stdlib `json`.
|
||||
```
|
||||
|
||||
**The crucial 2026 fact**: with Pydantic v2's `model_dump_json()`, **Pydantic-shaped responses no longer need orjson**. Adopt orjson where you are still going through `dict` / `list` / `dataclass`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Install
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
dependencies = [
|
||||
"orjson>=3.10",
|
||||
]
|
||||
```
|
||||
|
||||
orjson wheels are published for every major CPython version and platform (macOS, Linux glibc/musl, Windows, ARM64). No compilation step on install.
|
||||
|
||||
---
|
||||
|
||||
## 3. Basic usage
|
||||
|
||||
```python
|
||||
import orjson
|
||||
|
||||
# Serialization — returns bytes, not str
|
||||
raw: bytes = orjson.dumps({"hello": "world", "ts": datetime.now(UTC)})
|
||||
|
||||
# Deserialization
|
||||
data = orjson.loads(raw)
|
||||
```
|
||||
|
||||
Two things to internalize:
|
||||
|
||||
1. **`orjson.dumps` returns `bytes`**, not `str`. Stdlib `json.dumps` returns `str`. This is by design — most JSON destinations (sockets, files in binary mode, HTTP bodies) want bytes anyway, and skipping the encode/decode round trip is part of the speedup.
|
||||
2. **No `indent` arg.** orjson supports `OPT_INDENT_2` (and only 2-space indent) via flags. If you need other indentation, use stdlib `json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The option flags you actually use
|
||||
|
||||
```python
|
||||
import orjson
|
||||
|
||||
orjson.dumps(
|
||||
payload,
|
||||
option=(
|
||||
orjson.OPT_NAIVE_UTC # treat naive datetimes as UTC (recommended)
|
||||
| orjson.OPT_UTC_Z # render UTC as "...Z" instead of "+00:00"
|
||||
| orjson.OPT_SERIALIZE_NUMPY # serialize numpy arrays natively
|
||||
| orjson.OPT_SERIALIZE_DATACLASS # serialize @dataclass instances
|
||||
| orjson.OPT_NON_STR_KEYS # allow int / UUID / datetime dict keys
|
||||
# | orjson.OPT_SORT_KEYS # only when you need deterministic output
|
||||
# | orjson.OPT_INDENT_2 # only for human-readable output (slower)
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Each flag is opt-in for a reason — orjson defaults to spec-strict JSON.
|
||||
|
||||
The flag combination above is a sensible "production default" for application code. The `OPT_NAIVE_UTC | OPT_UTC_Z` pair is especially important: it produces RFC 3339 timestamps that every parser on earth accepts.
|
||||
|
||||
---
|
||||
|
||||
## 5. orjson + FastAPI
|
||||
|
||||
### 5.1 The legacy pattern: `ORJSONResponse`
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
app = FastAPI(default_response_class=ORJSONResponse)
|
||||
|
||||
@app.get("/items")
|
||||
async def get_items() -> dict[str, list[dict[str, int]]]:
|
||||
return {"items": [{"id": i, "qty": i * 2} for i in range(1000)]}
|
||||
```
|
||||
|
||||
`default_response_class=ORJSONResponse` swaps the global JSON encoder for orjson. **This affects only the response body serialization**, not request parsing — for request parsing, FastAPI still uses Pydantic.
|
||||
|
||||
### 5.2 The 2026 reality — Pydantic v2 vs orjson
|
||||
|
||||
With FastAPI 0.100+ on Pydantic v2:
|
||||
|
||||
- If your response is annotated with a Pydantic model, FastAPI calls `model_dump_json()` directly. **orjson is bypassed** even with `default_response_class=ORJSONResponse`, because the Pydantic serializer is already Rust-backed.
|
||||
- If your response is a raw `dict` / `list` / Python object, `ORJSONResponse` does kick in and saves real time.
|
||||
|
||||
The benchmark in `tiangolo/fastapi#11728` (Apr 2024) showed `model_dump_json()` is ~10–15% faster than `ORJSONResponse + model_dump()` for Pydantic-shaped responses. The shape of the data matters; on mixed-shape APIs, keep `ORJSONResponse` as the default and trust Pydantic's path for typed responses.
|
||||
|
||||
### 5.3 Recommended setup
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
app = FastAPI(
|
||||
default_response_class=ORJSONResponse, # benefits dict/list returns
|
||||
# Pydantic-typed returns automatically use pydantic-core serialization
|
||||
)
|
||||
```
|
||||
|
||||
**Do NOT** wrap Pydantic models manually:
|
||||
|
||||
```python
|
||||
# BAD — defeats Pydantic's optimized path
|
||||
@app.get("/users/{id}", response_class=ORJSONResponse)
|
||||
async def get_user(id: int) -> ORJSONResponse:
|
||||
user = await fetch_user(id)
|
||||
return ORJSONResponse(content=user.model_dump()) # extra dict trip
|
||||
|
||||
# GOOD — let FastAPI serialize the model
|
||||
@app.get("/users/{id}")
|
||||
async def get_user(id: int) -> User:
|
||||
return await fetch_user(id)
|
||||
```
|
||||
|
||||
### 5.4 Streaming responses
|
||||
|
||||
`ORJSONResponse` does not stream — it buffers the whole response. For SSE, NDJSON, or chunked JSON, use `StreamingResponse` and call `orjson.dumps` per chunk:
|
||||
|
||||
```python
|
||||
from fastapi.responses import StreamingResponse
|
||||
import orjson
|
||||
|
||||
async def ndjson_stream():
|
||||
async for row in fetch_rows():
|
||||
yield orjson.dumps(row) + b"\n"
|
||||
|
||||
@app.get("/export")
|
||||
async def export():
|
||||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||||
```
|
||||
|
||||
This is where orjson shines — per-chunk serialization in a tight loop, zero buffering.
|
||||
|
||||
---
|
||||
|
||||
## 6. orjson + Pydantic v2 (no FastAPI)
|
||||
|
||||
When you have a Pydantic model and want orjson's output for non-FastAPI contexts:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
import orjson
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
created: datetime
|
||||
|
||||
user = User(id=1, email="a@b.com", created=datetime.now(UTC))
|
||||
|
||||
# Option A — Pydantic's built-in Rust serializer (USE THIS by default)
|
||||
raw: bytes = user.model_dump_json().encode()
|
||||
# 2026: ~1.2× faster than orjson on the same payload, supports
|
||||
# every Pydantic feature (aliases, computed fields, json_schema_extra, etc.)
|
||||
|
||||
# Option B — orjson bridge for cases Pydantic does not cover
|
||||
raw: bytes = orjson.dumps(
|
||||
user,
|
||||
default=lambda obj: obj.model_dump() if isinstance(obj, BaseModel) else None,
|
||||
)
|
||||
# Useful when serializing nested non-Pydantic structures that contain
|
||||
# BaseModels — e.g. a list of dicts that each may contain a BaseModel.
|
||||
```
|
||||
|
||||
For routine "serialize one Pydantic model to JSON", `model_dump_json()` wins on speed AND feature parity. Reach for orjson only at the *container* level (a dict of mixed types).
|
||||
|
||||
### Custom `default=` callback — the universal extension point
|
||||
|
||||
```python
|
||||
import orjson
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
|
||||
def _default(obj):
|
||||
if isinstance(obj, BaseModel):
|
||||
return obj.model_dump()
|
||||
if isinstance(obj, Decimal):
|
||||
return str(obj)
|
||||
if isinstance(obj, set):
|
||||
return list(obj)
|
||||
raise TypeError(f"orjson: cannot serialize {type(obj).__name__}")
|
||||
|
||||
orjson.dumps(payload, default=_default, option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z)
|
||||
```
|
||||
|
||||
The `default=` callback runs once per unrecognized type, then orjson caches the path. Performance impact on subsequent calls is negligible.
|
||||
|
||||
---
|
||||
|
||||
## 7. Caching, queues, logging — the prime orjson use cases
|
||||
|
||||
These are where orjson pays off most clearly because there is no Pydantic in the loop:
|
||||
|
||||
### Redis cache
|
||||
|
||||
```python
|
||||
import orjson
|
||||
import redis.asyncio as redis
|
||||
|
||||
r = redis.from_url("redis://localhost")
|
||||
|
||||
async def set_cache(key: str, value: dict) -> None:
|
||||
await r.set(key, orjson.dumps(value), ex=3600)
|
||||
|
||||
async def get_cache(key: str) -> dict | None:
|
||||
raw = await r.get(key)
|
||||
return orjson.loads(raw) if raw else None
|
||||
```
|
||||
|
||||
`orjson` over stdlib `json` here saves ~5–10× on the serialize step for typical cache payloads. Multiply by request rate.
|
||||
|
||||
### Task queue payloads (Celery, RQ, dramatiq)
|
||||
|
||||
```python
|
||||
# Celery custom serializer
|
||||
from kombu.serialization import register
|
||||
import orjson
|
||||
|
||||
def _orjson_dumps(obj):
|
||||
return orjson.dumps(obj, option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z).decode()
|
||||
|
||||
def _orjson_loads(s):
|
||||
return orjson.loads(s)
|
||||
|
||||
register("orjson", _orjson_dumps, _orjson_loads,
|
||||
content_type="application/x-orjson",
|
||||
content_encoding="utf-8")
|
||||
```
|
||||
|
||||
Same speedup, applied to every task payload encode/decode.
|
||||
|
||||
### Structured logging (structlog, custom slog)
|
||||
|
||||
```python
|
||||
import structlog
|
||||
import orjson
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.JSONRenderer(serializer=orjson.dumps),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
structlog's `JSONRenderer` accepts any callable; orjson is the obvious default. Logging hot paths benefit dramatically — every log line at info level becomes ~5× cheaper to render.
|
||||
|
||||
---
|
||||
|
||||
## 8. Gotchas
|
||||
|
||||
### `orjson.dumps` returns bytes, not str
|
||||
|
||||
```python
|
||||
# BAD — concatenating bytes and str
|
||||
log.info("payload: " + orjson.dumps(data)) # TypeError
|
||||
|
||||
# GOOD
|
||||
log.info("payload: %s", orjson.dumps(data).decode())
|
||||
# or
|
||||
log.info("payload: %s", orjson.dumps(data)) # let the formatter handle it
|
||||
```
|
||||
|
||||
### No `cls=` argument for custom encoders
|
||||
|
||||
orjson uses `default=` only. If you have a custom `JSONEncoder` subclass from stdlib `json`, port its `default()` method to a `default=` callable.
|
||||
|
||||
### Subclasses of `dict` / `list` are NOT serialized as their parent
|
||||
|
||||
```python
|
||||
class StrictDict(dict): ...
|
||||
d = StrictDict({"k": "v"})
|
||||
|
||||
import json
|
||||
json.dumps(d) # OK — stdlib walks subclasses
|
||||
orjson.dumps(d) # TypeError — orjson is strict by design
|
||||
orjson.dumps(d, option=orjson.OPT_PASSTHROUGH_SUBCLASS) # then route via default=
|
||||
```
|
||||
|
||||
Set `OPT_PASSTHROUGH_SUBCLASS` and handle the subclass in `default=`. The design discourages accidental subclass usage that breaks elsewhere.
|
||||
|
||||
### `int` overflow
|
||||
|
||||
orjson refuses to encode integers larger than 2⁵³ - 1 by default (the IEEE-754 double-precision safe-integer limit — what JavaScript can round-trip). For larger ints, opt in:
|
||||
|
||||
```python
|
||||
orjson.dumps(huge_int, option=orjson.OPT_STRICT_INTEGER) # error
|
||||
orjson.dumps(huge_int) # default — int is encoded as JSON number
|
||||
# JavaScript clients lose precision past 2^53; consider sending as string
|
||||
```
|
||||
|
||||
This is more spec-strict than stdlib `json`, which silently emits ints of any size.
|
||||
|
||||
### Timezone-naive datetimes
|
||||
|
||||
By default, orjson treats naive `datetime` as the system local timezone — almost never what you want. **Always set `OPT_NAIVE_UTC`** to treat naive datetimes as UTC, or use timezone-aware datetimes (which is the better long-term habit).
|
||||
|
||||
---
|
||||
|
||||
## 9. Benchmark — should I actually adopt this?
|
||||
|
||||
The numbers below are 2024–2026 averages from `tiangolo/fastapi#11728` and orjson's own benchmark suite, on Python 3.13, modern x86_64:
|
||||
|
||||
| Payload | stdlib `json` | `orjson` | `model_dump_json()` (Pydantic v2) |
|
||||
|---|---|---|---|
|
||||
| Small dict (100 fields) | 1.0× | **8×** | n/a |
|
||||
| List of 10k dicts | 1.0× | **11×** | n/a |
|
||||
| Pydantic model with 20 fields | 1.0× (after `model_dump()`) | 5× (with `default=` bridge) | **6×** |
|
||||
| Datetime-heavy payload | 1.0× (after manual ISO conv) | **9×** | 6× |
|
||||
| numpy array (1M floats) | impossible without manual conv | **20×** vs json+tolist | n/a |
|
||||
|
||||
The takeaways:
|
||||
|
||||
- For raw dict/list/datetime, **orjson is dramatically faster**.
|
||||
- For Pydantic models, **`model_dump_json()` is already faster than orjson+bridge**.
|
||||
- For numpy, orjson is the only sane choice.
|
||||
|
||||
In production, the actual measured win on a FastAPI app with mixed payloads is typically 5–15% reduction in p99 latency. Worth the one-line `default_response_class=ORJSONResponse` switch.
|
||||
|
||||
---
|
||||
|
||||
## 10. When NOT to adopt orjson
|
||||
|
||||
- The codebase is small, JSON is not a bottleneck, and you have no measured perf concern.
|
||||
- You depend on stdlib `json`'s `cls=` arg or its lax tolerance for non-spec input (NaN, Infinity, comments).
|
||||
- You need pretty-printed JSON with custom indent — orjson only supports 2-space indent via the flag.
|
||||
- You need pure-Python portability (e.g., MicroPython, no-wheel platforms) — orjson is a compiled Rust extension.
|
||||
|
||||
If the choice is "add a dependency that does 5–10× the speed on serialization for free", the answer is almost always yes. The "almost" is in the bullets above.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- orjson: https://github.com/ijl/orjson
|
||||
- Pydantic v2 `model_dump_json`: https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump_json
|
||||
- FastAPI `ORJSONResponse`: https://fastapi.tiangolo.com/advanced/custom-response/#use-orjsonresponse
|
||||
- "FastAPI + orjson vs Pydantic v2" benchmark: https://github.com/fastapi/fastapi/discussions/11728
|
||||
- structlog JSON rendering: https://www.structlog.org/en/stable/api.html#structlog.processors.JSONRenderer
|
||||
@@ -0,0 +1,285 @@
|
||||
# PydanticAI Reference (v1.x, 2026)
|
||||
|
||||
> Canonical patterns for wiring PydanticAI agents. Target: production usage, late-2025 / 2026.
|
||||
> Source: [ai.pydantic.dev](https://ai.pydantic.dev) and [pydantic/pydantic-ai@`cad9569`](https://github.com/pydantic/pydantic-ai/blob/cad956910079737ea0886b50cef15777208f92e6).
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Constructor
|
||||
|
||||
```python
|
||||
from pydantic_ai import Agent
|
||||
|
||||
agent = Agent(
|
||||
'openai:gpt-5.2', # model (str | Model | None)
|
||||
output_type=MyOutputModel, # structured output type; default=str
|
||||
instructions='You are a...', # static or callable instructions
|
||||
system_prompt='Be concise.', # static system prompt(s)
|
||||
deps_type=MyDeps, # dependency type for type-checking only
|
||||
name='my-agent', # optional, inferred from var name if omitted
|
||||
retries=1, # default retries for tools + output validation
|
||||
output_retries=None, # override retries for output validation only
|
||||
tools=[my_tool], # list of Tool objects or plain functions
|
||||
defer_model_check=False, # set True to skip env-var check at init time
|
||||
end_strategy='early', # 'early' | 'graceful' | 'exhaustive'
|
||||
)
|
||||
```
|
||||
|
||||
**Breaking change (v1.88.0)**: `result_type` was renamed to `output_type`. Use `output_type`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model Strings
|
||||
|
||||
Format: `provider:model-name`. The framework infers the provider from the prefix.
|
||||
|
||||
| Provider prefix | Example |
|
||||
|---|---|
|
||||
| `openai:` | `'openai:gpt-5.2'`, `'openai:gpt-4o'` |
|
||||
| `anthropic:` | `'anthropic:claude-sonnet-4-6'`, `'anthropic:claude-opus-4-1'` |
|
||||
| `google-gla:` | `'google-gla:gemini-3-flash-preview'` |
|
||||
| `google-vertex:` | `'google-vertex:gemini-3-pro-preview'` |
|
||||
| `bedrock:` | `'bedrock:anthropic.claude-sonnet-4-6'` |
|
||||
| `xai:` / `grok:` | `'xai:grok-3'`, `'grok:grok-3-fast'` |
|
||||
| `deepseek:` | `'deepseek:deepseek-chat'` |
|
||||
| `cohere:` | `'cohere:command-r-08-2024'` |
|
||||
| `gateway/...` | `'gateway/openai:gpt-5.2'` (PydanticAI Gateway) |
|
||||
|
||||
Model can also be omitted at construction and passed per-run: `agent.run(prompt, model='openai:gpt-5.2')`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tools
|
||||
|
||||
### Decorator syntax
|
||||
|
||||
```python
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
agent = Agent('openai:gpt-5.2', deps_type=str)
|
||||
|
||||
@agent.tool # default: receives RunContext as first arg
|
||||
async def greet(ctx: RunContext[str], name: str) -> str:
|
||||
return f"Hello {ctx.deps}, {name}!"
|
||||
|
||||
@agent.tool_plain # no context needed
|
||||
async def roll_dice(sides: int) -> int:
|
||||
import random
|
||||
return random.randint(1, sides)
|
||||
```
|
||||
|
||||
### `RunContext[Deps]`
|
||||
|
||||
First parameter of `@agent.tool` functions. Carries:
|
||||
|
||||
- `ctx.deps` — the dependency instance
|
||||
- `ctx.model` — the model being used
|
||||
- `ctx.usage` — token usage so far
|
||||
- `ctx.messages` — conversation history
|
||||
- `ctx.retry` / `ctx.max_retries` — current retry count
|
||||
- `ctx.agent` — the running agent instance
|
||||
|
||||
Use `@agent.tool_plain` when the tool does **not** need any of the above.
|
||||
|
||||
---
|
||||
|
||||
## 4. Structured Output
|
||||
|
||||
Pass a Pydantic `BaseModel` (or `bool`, `int`, `list[str]`, etc.) as `output_type`. The result is accessed via `.output`.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from pydantic_ai import Agent
|
||||
|
||||
class City(BaseModel):
|
||||
name: str
|
||||
country: str
|
||||
population_millions: float
|
||||
|
||||
agent = Agent('openai:gpt-5.2', output_type=City)
|
||||
result = agent.run_sync('Tell me about Tokyo')
|
||||
print(result.output) # City(name='Tokyo', country='Japan', ...)
|
||||
print(result.output.name) # 'Tokyo'
|
||||
```
|
||||
|
||||
**Note**: `result.data` was renamed; the canonical accessor is `result.output`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Async vs Sync
|
||||
|
||||
| Method | Mode | Returns |
|
||||
|---|---|---|
|
||||
| `await agent.run(prompt, ...)` | async | `AgentRunResult[OutputDataT]` |
|
||||
| `agent.run_sync(prompt, ...)` | sync | `AgentRunResult[OutputDataT]` |
|
||||
| `async with agent.run_stream(prompt, ...) as response:` | async streaming | `StreamedRunResult` |
|
||||
|
||||
```python
|
||||
# Sync
|
||||
result = agent.run_sync('What is the capital of Italy?')
|
||||
print(result.output)
|
||||
|
||||
# Async
|
||||
result = await agent.run('What is the capital of France?')
|
||||
print(result.output)
|
||||
|
||||
# Streaming
|
||||
async with agent.run_stream('What is the capital of the UK?') as response:
|
||||
async for text in response.stream_text():
|
||||
print(text, end='')
|
||||
# After streaming finishes:
|
||||
print(response.output)
|
||||
```
|
||||
|
||||
`run_sync()` is a convenience wrapper over `loop.run_until_complete(self.run(...))`. Do not use it inside an active async context.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dependencies
|
||||
|
||||
Use a `@dataclass` container, pass the **type** to `deps_type`, and pass an **instance** to `deps` at run time.
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
import httpx
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
api_key: str
|
||||
http_client: httpx.AsyncClient
|
||||
|
||||
agent = Agent(
|
||||
'openai:gpt-5.2',
|
||||
deps_type=Deps,
|
||||
)
|
||||
|
||||
@agent.tool
|
||||
async def fetch_data(ctx: RunContext[Deps], endpoint: str) -> str:
|
||||
r = await ctx.deps.http_client.get(
|
||||
endpoint,
|
||||
headers={'Authorization': f'Bearer {ctx.deps.api_key}'},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
|
||||
async def main():
|
||||
async with httpx.AsyncClient() as client:
|
||||
deps = Deps(api_key='sk-...', http_client=client)
|
||||
result = await agent.run('Get /users', deps=deps)
|
||||
print(result.output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Types & Retrying from a Tool
|
||||
|
||||
```python
|
||||
from pydantic_ai import Agent, ModelRetry, UnexpectedModelBehavior, capture_run_messages
|
||||
|
||||
agent = Agent('openai:gpt-5.2', retries=3)
|
||||
|
||||
@agent.tool_plain
|
||||
def calc_volume(size: int) -> int:
|
||||
if size == 42:
|
||||
return size ** 3
|
||||
raise ModelRetry('Please try again with size 42.')
|
||||
|
||||
with capture_run_messages() as messages:
|
||||
try:
|
||||
result = agent.run_sync('Get the volume of a box with size 6.')
|
||||
except UnexpectedModelBehavior as e:
|
||||
print('Error:', e) # "Tool 'calc_volume' exceeded max retries count of 3"
|
||||
print('Cause:', e.__cause__) # ModelRetry('Please try again...')
|
||||
print('Messages:', messages)
|
||||
```
|
||||
|
||||
- **`ModelRetry`** — raise from a tool, output validator, or capability hook to ask the model to retry.
|
||||
- **`UnexpectedModelBehavior`** — raised when the retry limit is exceeded or the model API returns an unrecoverable error.
|
||||
- **`capture_run_messages()`** — context manager that records all messages exchanged during a run for debugging.
|
||||
|
||||
---
|
||||
|
||||
## 8. Logfire Integration
|
||||
|
||||
One-line setup if the `logfire` extra is installed (included in the default `pydantic-ai` package):
|
||||
|
||||
```python
|
||||
import logfire
|
||||
|
||||
logfire.configure() # reads token from .logfire directory
|
||||
logfire.instrument_pydantic_ai() # auto-traces all agent runs
|
||||
```
|
||||
|
||||
Alternatively, set `instrument=True` on the agent:
|
||||
|
||||
```python
|
||||
agent = Agent('openai:gpt-5.2', instrument=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Minimal Complete Snippets
|
||||
|
||||
### (a) Basic agent with structured output
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from pydantic_ai import Agent
|
||||
|
||||
class City(BaseModel):
|
||||
name: str
|
||||
country: str
|
||||
|
||||
agent = Agent('openai:gpt-5.2', output_type=City)
|
||||
result = agent.run_sync('Tell me about Paris')
|
||||
print(result.output) # City(name='Paris', country='France')
|
||||
```
|
||||
|
||||
### (b) Agent with tools and dependencies
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
api_key: str
|
||||
|
||||
agent = Agent('openai:gpt-5.2', deps_type=Deps)
|
||||
|
||||
@agent.tool
|
||||
async def get_secret(ctx: RunContext[Deps], code: str) -> str:
|
||||
if code == '1234':
|
||||
return f'secret-for-{ctx.deps.api_key}'
|
||||
return 'wrong code'
|
||||
|
||||
result = agent.run_sync('My code is 1234', deps=Deps(api_key='sk-abc'))
|
||||
print(result.output)
|
||||
```
|
||||
|
||||
### (c) Async streaming
|
||||
|
||||
```python
|
||||
import anyio
|
||||
from pydantic_ai import Agent
|
||||
|
||||
agent = Agent('openai:gpt-5.2')
|
||||
|
||||
async def main() -> None:
|
||||
async with agent.run_stream('Write a haiku about Python') as response:
|
||||
async for text in response.stream_text():
|
||||
print(text, end='')
|
||||
print('\n---')
|
||||
print('Final:', response.output)
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version Notes
|
||||
|
||||
- **V1** reached API stability in September 2025. Breaking changes are reserved for V2 (earliest April 2026).
|
||||
- **v1.88.0** renamed `result_type` → `output_type` and `result_tool_name` / `result_tool_description` were removed. Use `output_type`.
|
||||
- The canonical accessor for run results is `result.output` (not `result.data`).
|
||||
@@ -0,0 +1,232 @@
|
||||
# Strict pyproject.toml (basedpyright + ruff + uv)
|
||||
|
||||
The canonical "super strict but sane" config for modern Python projects. Copy-paste, then add your own dependencies.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
```bash
|
||||
# Application
|
||||
uv init --app myproject
|
||||
cd myproject
|
||||
|
||||
# Library (publishable to PyPI)
|
||||
uv init --lib mylibrary
|
||||
cd mylibrary
|
||||
|
||||
# Add dev tools
|
||||
uv add --dev basedpyright ruff pytest
|
||||
```
|
||||
|
||||
`uv init` creates `pyproject.toml`, `.python-version`, and `src/` layout. Replace its `pyproject.toml` `[tool.*]` sections with the block below.
|
||||
|
||||
## The full pyproject.toml
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "myproject"
|
||||
version = "0.1.0"
|
||||
description = "..."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.21",
|
||||
"ruff>=0.8",
|
||||
"pytest>=8",
|
||||
"pytest-cov>=5",
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# basedpyright - typeCheckingMode = "all" sets every report flag to error
|
||||
# Source: https://docs.basedpyright.com/latest/configuration/config-files/
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
[tool.basedpyright]
|
||||
typeCheckingMode = "all"
|
||||
pythonVersion = "3.13"
|
||||
pythonPlatform = "All" # default in basedpyright; explicit for clarity
|
||||
include = ["src", "tests"]
|
||||
exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist"]
|
||||
|
||||
# Strict enforcement extras (most are already "error" under "all" mode,
|
||||
# but listing them explicitly documents the intent)
|
||||
reportUnusedCallResult = "warning" # flag ignored return values
|
||||
reportUnnecessaryTypeIgnoreComment = "error" # stale type: ignore comments must die
|
||||
reportUnusedVariable = "error" # unused variables are errors
|
||||
reportMissingParameterType = "error" # every parameter must have a type
|
||||
reportMissingReturnType = "error" # every function must declare its return type
|
||||
reportPrivateUsage = "error" # respect _private convention
|
||||
|
||||
# Optional: gradual adoption baseline
|
||||
# baselineFile = "./.basedpyright/baseline.json"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# ruff - select = ["ALL"] enables every rule, then we ignore the
|
||||
# small set that conflicts with the formatter or is not useful.
|
||||
# Source: https://docs.astral.sh/ruff/linter/#rule-selection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 88 # ruff/black default; 100 or 120 also fine
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
# Formatter conflicts (ruff itself tells you to ignore these)
|
||||
"COM812", # missing trailing comma
|
||||
"ISC001", # implicit string concat
|
||||
# Docstyle conflicts (pick D211 over D203, D212 over D213)
|
||||
"D203",
|
||||
"D213",
|
||||
# Project-specific noise
|
||||
"CPY001", # missing copyright notice
|
||||
"FBT001", # boolean positional arg in def
|
||||
"FBT002", # boolean positional default in def
|
||||
"TD002", # missing TODO author
|
||||
"TD003", # missing TODO link
|
||||
"FIX002", # line contains TODO (TODOs are allowed)
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
"S101", # `assert` is the entire point of pytest
|
||||
"ARG", # unused args (fixtures appear unused)
|
||||
"PLR2004", # magic numbers in test data
|
||||
"SLF001", # tests need access to private members
|
||||
"D", # docstrings not required in tests
|
||||
]
|
||||
"scripts/**/*.py" = [
|
||||
"T201", # `print` allowed in scripts
|
||||
"INP001", # implicit namespace package
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google" # or "numpy" / "pep257"
|
||||
|
||||
[tool.ruff.lint.flake8-bugbear]
|
||||
# typer / fastapi rely on call-as-default for parameter metadata.
|
||||
# Without this, ruff B008 ("function call in default") fires on every typer/fastapi route.
|
||||
extend-immutable-calls = [
|
||||
"typer.Argument",
|
||||
"typer.Option",
|
||||
"fastapi.Depends",
|
||||
"fastapi.Query",
|
||||
"fastapi.Path",
|
||||
"fastapi.Body",
|
||||
"fastapi.Header",
|
||||
"fastapi.Cookie",
|
||||
"fastapi.File",
|
||||
"fastapi.Form",
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
docstring-code-format = true
|
||||
docstring-code-line-length = "dynamic"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# pytest
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = [
|
||||
"-ra",
|
||||
"--strict-config",
|
||||
"--strict-markers",
|
||||
]
|
||||
filterwarnings = ["error"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# coverage
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"if typing.TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
```
|
||||
|
||||
## Why these settings
|
||||
|
||||
### basedpyright `typeCheckingMode = "all"`
|
||||
|
||||
basedpyright's modes, strictest first:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `"all"` | Every diagnostic at `error` |
|
||||
| `"recommended"` | Same rules; less severe ones at `warning`; `failOnWarnings = true` makes CI still fail |
|
||||
| `"strict"` | pyright's strict mode |
|
||||
| `"standard"` | Default |
|
||||
| `"basic"` / `"off"` | Loose / disabled |
|
||||
|
||||
`"all"` enables basedpyright-exclusive rules pyright lacks: `reportImplicitOverride`, `reportImplicitStringConcatenation`, `reportIncompatibleUnannotatedOverride`, `reportUnannotatedClassAttribute`. No need to opt-in to additional flags.
|
||||
|
||||
`pythonPlatform = "All"` is basedpyright's default (better than pyright's host-OS default) - it errors on platform-specific imports that fail on other OSes.
|
||||
|
||||
### ruff `select = ["ALL"]`
|
||||
|
||||
The official docs say *"Use ALL with discretion. Enabling ALL will implicitly enable new rules whenever you upgrade."* For a strict skill that is the intended behavior - every new ruff rule should be considered an error until you justify ignoring it.
|
||||
|
||||
The minimal ignore set:
|
||||
|
||||
| Rule | Reason |
|
||||
|---|---|
|
||||
| `COM812`, `ISC001` | Conflict with `ruff format` (ruff itself documents this) |
|
||||
| `D203` vs `D211`, `D213` vs `D212` | Mutually-exclusive docstring conventions; pick the modern one |
|
||||
| `CPY001` | Most projects don't need a copyright header on every file |
|
||||
| `FBT001`, `FBT002` | Boolean flags are ergonomic for CLI/typer; ban makes typer awkward |
|
||||
| `TD002`, `TD003`, `FIX002` | TODOs without a JIRA link are fine in solo / internal code |
|
||||
|
||||
`ANN101` and `ANN102` were **removed in ruff 0.8.0** (Nov 2024). Do NOT include them in `ignore` - ruff errors on unknown rule codes.
|
||||
|
||||
`per-file-ignores` for `tests/**` is the standard pattern from real-world repos like `community-of-python/auto-typing-final` and `Preston-Landers/concurrent-log-handler`.
|
||||
|
||||
## CI gate
|
||||
|
||||
```bash
|
||||
# In CI, fail on any violation:
|
||||
uv run basedpyright
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
A single `make ci` target combining the four works fine.
|
||||
|
||||
## Enforcement summary
|
||||
|
||||
The config above, combined with `scripts/check-no-excuse-rules.py`, enforces:
|
||||
|
||||
| What | How |
|
||||
|---|---|
|
||||
| Exhaustive match | basedpyright `all` mode + `assert_never` |
|
||||
| No `Any` | basedpyright `all` mode + script `cast-any` rule |
|
||||
| Ignored return values | `reportUnusedCallResult = "warning"` |
|
||||
| Immutable default | Script `mutable-dataclass` + `missing-slots` rules |
|
||||
| No null surprise | basedpyright strict `None` analysis |
|
||||
| Constants are const | basedpyright catches `Final` reassignment |
|
||||
| Unused variables | `reportUnusedVariable = "error"` |
|
||||
|
||||
## Sources
|
||||
|
||||
- basedpyright modes: <https://docs.basedpyright.com/latest/configuration/config-files/#type-check-diagnostics-settings>
|
||||
- basedpyright `"all"` vs `"recommended"`: <https://docs.basedpyright.com/latest/configuration/config-files/#recommended-and-all>
|
||||
- basedpyright better defaults: <https://docs.basedpyright.com/latest/benefits-over-pyright/better-defaults/>
|
||||
- ruff rule selection: <https://docs.astral.sh/ruff/linter/#rule-selection>
|
||||
- ruff ANN101/ANN102 removed: <https://github.com/astral-sh/ruff/pull/14384>
|
||||
- Real-world ALL config: <https://github.com/community-of-python/auto-typing-final/blob/main/pyproject.toml>
|
||||
- PEP 735 dependency-groups: <https://peps.python.org/pep-0735/>
|
||||
@@ -0,0 +1,201 @@
|
||||
# Textual TUI
|
||||
|
||||
Textual builds rich, mouse-aware, scrollable, mobile-style TUIs on top of `rich`. Replaces curses, urwid, blessed.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
uv add textual
|
||||
uv add --dev textual-dev # textual console + run --dev for hot reload
|
||||
```
|
||||
|
||||
## Minimal app
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer, Button, Label
|
||||
from textual.containers import Vertical
|
||||
|
||||
|
||||
class CounterApp(App[None]):
|
||||
"""A trivial counter app."""
|
||||
|
||||
BINDINGS = [("q", "quit", "Quit")]
|
||||
CSS = """
|
||||
#count {
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
background: $boost;
|
||||
}
|
||||
"""
|
||||
|
||||
count: int = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Vertical():
|
||||
yield Label("0", id="count")
|
||||
yield Button("Increment", id="inc", variant="primary")
|
||||
yield Button("Reset", id="reset", variant="warning")
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "inc":
|
||||
self.count += 1
|
||||
elif event.button.id == "reset":
|
||||
self.count = 0
|
||||
self.query_one("#count", Label).update(str(self.count))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
CounterApp().run()
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run python counter.py
|
||||
```
|
||||
|
||||
For hot reload during development:
|
||||
|
||||
```bash
|
||||
uv run textual run --dev counter.py
|
||||
```
|
||||
|
||||
## Reactive attributes
|
||||
|
||||
Textual's `reactive()` descriptor turns a class attribute into something that watches assignments and re-renders automatically. Replaces the manual `query_one` + `update` dance.
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
class CountWidget(Label):
|
||||
count: reactive[int] = reactive(0)
|
||||
|
||||
def render(self) -> str:
|
||||
return f"Count: {self.count}"
|
||||
|
||||
|
||||
class CounterApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield CountWidget()
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
if event.key == "space":
|
||||
self.query_one(CountWidget).count += 1
|
||||
```
|
||||
|
||||
`reactive()` triggers `render()` (or `watch_<attr>` and `validate_<attr>` callbacks if defined). Use `recompose=True` if you need to call `compose()` again on change.
|
||||
|
||||
## Async work — workers
|
||||
|
||||
NEVER block the event loop. For network/disk/CPU work, use `@work` (creates a worker) or `run_worker`.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Input, Static
|
||||
from textual.work import work
|
||||
|
||||
|
||||
class FetchApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="URL", id="url")
|
||||
yield Static(id="result")
|
||||
|
||||
@work(exclusive=True)
|
||||
async def fetch(self, url: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
self.query_one("#result", Static).update(f"{response.status_code} - {len(response.text)} bytes")
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self.fetch(event.value)
|
||||
```
|
||||
|
||||
`exclusive=True` cancels the previous worker if the user submits a new URL before the first finishes. Workers integrate with Textual's lifecycle - they're cancelled when the app exits.
|
||||
|
||||
`@work` is asyncio-flavoured under the hood. That is fine - it does not violate the no-asyncio rule because you are calling Textual's API, not importing asyncio yourself. Inside the worker body, use `httpx.AsyncClient` and other anyio-friendly libraries.
|
||||
|
||||
## Action handlers
|
||||
|
||||
Bind keys to method calls via `BINDINGS` and `action_*` methods.
|
||||
|
||||
```python
|
||||
class App(App):
|
||||
BINDINGS = [
|
||||
("ctrl+s", "save", "Save"),
|
||||
("ctrl+r", "reload", "Reload"),
|
||||
]
|
||||
|
||||
def action_save(self) -> None:
|
||||
# Called on ctrl+s
|
||||
...
|
||||
|
||||
def action_reload(self) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
Bindings can also include the `priority=True` flag to fire before children get a chance.
|
||||
|
||||
## CSS
|
||||
|
||||
Textual's CSS supports selectors, variables (`$primary`, `$boost`), animations. Inline via `CSS = "..."` or external via `CSS_PATH = "app.tcss"`.
|
||||
|
||||
```css
|
||||
Screen {
|
||||
background: $surface;
|
||||
color: $text;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 30;
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
Button.danger {
|
||||
background: $error;
|
||||
}
|
||||
```
|
||||
|
||||
Reload with `r` in dev mode (`textual run --dev`).
|
||||
|
||||
## Testing
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from myapp import CounterApp
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_counter_increments() -> None:
|
||||
app = CounterApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("#inc")
|
||||
await pilot.click("#inc")
|
||||
assert app.count == 2
|
||||
```
|
||||
|
||||
`pilot.click(selector)`, `pilot.press("q")`, `pilot.pause()` for waiting on the next frame.
|
||||
|
||||
## When NOT to use Textual
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| One-off CLI with structured output | typer + rich |
|
||||
| Progress bar in a script | rich.progress |
|
||||
| Tabular display of query results | rich.table |
|
||||
| Full-screen app with state, input, mouse | Textual |
|
||||
|
||||
A pretty CLI is not a TUI. Reach for Textual when the user expects to navigate a UI, not when you want colours.
|
||||
|
||||
## Sources
|
||||
|
||||
- Textual docs: <https://textual.textualize.io>
|
||||
- Textual tutorial: <https://textual.textualize.io/tutorial/>
|
||||
- API reference: <https://textual.textualize.io/api/>
|
||||
@@ -0,0 +1,176 @@
|
||||
# Type Patterns
|
||||
|
||||
How to use Python's type system to catch bugs at check time, not runtime.
|
||||
|
||||
---
|
||||
|
||||
## NewType — distinct primitives
|
||||
|
||||
Same runtime type, different meaning. The type checker prevents mixing.
|
||||
|
||||
```python
|
||||
from typing import NewType
|
||||
|
||||
UserId = NewType("UserId", int)
|
||||
MovieId = NewType("MovieId", int)
|
||||
Email = NewType("Email", str)
|
||||
Seconds = NewType("Seconds", float)
|
||||
Milliseconds = NewType("Milliseconds", float)
|
||||
|
||||
def get_user(user_id: UserId) -> User: ...
|
||||
def get_movie(movie_id: MovieId) -> Movie: ...
|
||||
def sleep(duration: Seconds) -> None: ...
|
||||
|
||||
uid = UserId(42)
|
||||
mid = MovieId(42)
|
||||
|
||||
get_user(uid) # OK
|
||||
get_user(mid) # type error: MovieId is not UserId
|
||||
get_user(42) # type error: int is not UserId
|
||||
sleep(Milliseconds(100.0)) # type error
|
||||
```
|
||||
|
||||
**Use when**: IDs, indices, keys, units of measurement — any pair where swapping is a bug.
|
||||
**Skip when**: ephemeral local math where branding adds noise with zero safety gain.
|
||||
|
||||
---
|
||||
|
||||
## Final — constants are const
|
||||
|
||||
Module-level constants declare their intent. Reassignment is a type error.
|
||||
|
||||
```python
|
||||
from typing import Final
|
||||
|
||||
MAX_RETRIES: Final = 3
|
||||
API_BASE_URL: Final = "https://api.example.com"
|
||||
DEFAULT_TIMEOUT: Final = 30.0
|
||||
|
||||
MAX_RETRIES = 5 # type error: cannot assign to Final
|
||||
```
|
||||
|
||||
If it changes at runtime, it's not a constant — make it a function parameter or config field.
|
||||
|
||||
---
|
||||
|
||||
## TypeAlias — name complex types
|
||||
|
||||
If a union or generic appears more than once, give it a name.
|
||||
|
||||
```python
|
||||
# Python 3.12+
|
||||
type JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
type Headers = dict[str, str]
|
||||
type Middleware = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
# Pre-3.12
|
||||
from typing import TypeAlias
|
||||
|
||||
JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## StrEnum / IntEnum — closed sets
|
||||
|
||||
Any fixed set of known values. No string literals scattered through code.
|
||||
|
||||
```python
|
||||
from enum import StrEnum, IntEnum, unique
|
||||
|
||||
@unique
|
||||
class Role(StrEnum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
GUEST = "guest"
|
||||
|
||||
@unique
|
||||
class HttpStatus(IntEnum):
|
||||
OK = 200
|
||||
NOT_FOUND = 404
|
||||
INTERNAL_ERROR = 500
|
||||
|
||||
# BAD
|
||||
def check_role(role: str) -> bool: ...
|
||||
|
||||
# GOOD
|
||||
def check_role(role: Role) -> bool: ...
|
||||
```
|
||||
|
||||
`StrEnum` when values serialize as strings (API, DB). `IntEnum` for numeric codes. Plain `Enum` for pure labels.
|
||||
|
||||
---
|
||||
|
||||
## Type narrowing — let the checker follow your logic
|
||||
|
||||
`isinstance`, `is None`, and `match` narrow types automatically. Use them instead of `cast`.
|
||||
|
||||
```python
|
||||
def process(value: str | int | None) -> str:
|
||||
if value is None:
|
||||
return "nothing"
|
||||
# checker knows: str | int
|
||||
|
||||
if isinstance(value, str):
|
||||
return value.upper()
|
||||
# checker knows: int
|
||||
|
||||
return str(value * 2)
|
||||
```
|
||||
|
||||
### TypeGuard for custom narrowing
|
||||
|
||||
```python
|
||||
from typing import TypeGuard
|
||||
|
||||
def is_valid_email(value: str) -> TypeGuard[Email]:
|
||||
return "@" in value and "." in value.split("@")[1]
|
||||
|
||||
def send(addr: str) -> None:
|
||||
if not is_valid_email(addr):
|
||||
raise ValueError(addr)
|
||||
# checker knows: addr is Email
|
||||
deliver(addr)
|
||||
```
|
||||
|
||||
### TypeIs (Python 3.13+) — the strict version
|
||||
|
||||
`TypeIs` is stricter than `TypeGuard` — it narrows in both `if` and `else` branches.
|
||||
|
||||
```python
|
||||
from typing import TypeIs
|
||||
|
||||
def is_str(value: str | int) -> TypeIs[str]:
|
||||
return isinstance(value, str)
|
||||
|
||||
def handle(v: str | int) -> None:
|
||||
if is_str(v):
|
||||
print(v.upper()) # checker knows: str
|
||||
else:
|
||||
print(v + 1) # checker knows: int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Union syntax
|
||||
|
||||
Always `X | Y`. Never `Union[X, Y]` or `Optional[X]`.
|
||||
|
||||
```python
|
||||
# BAD
|
||||
from typing import Union, Optional
|
||||
def f(x: Optional[int]) -> Union[str, int]: ...
|
||||
|
||||
# GOOD
|
||||
def f(x: int | None) -> str | int: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Python docs: [typing — NewType](https://docs.python.org/3/library/typing.html#newtype)
|
||||
- Python docs: [typing — Final](https://docs.python.org/3/library/typing.html#typing.Final)
|
||||
- Python docs: [typing — TypeGuard](https://docs.python.org/3/library/typing.html#typing.TypeGuard)
|
||||
- PEP 604: [Union syntax X | Y](https://peps.python.org/pep-0604/)
|
||||
- PEP 742: [TypeIs](https://peps.python.org/pep-0742/)
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
# Rust Undefined Behavior Exorcist
|
||||
|
||||
You are a UB hunter. Your job is to find, classify, prove, and eliminate every instance of undefined behavior in Rust code. **Miri is your primary weapon** — everything else supplements where Miri cannot reach.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
1. **Miri first, always.** Before reading a single line of `unsafe`, run Miri. Before proposing a fix, run Miri. After applying a fix, run Miri. Miri is the oracle.
|
||||
2. **Classify before fixing.** Every UB finding gets classified against the 14-category taxonomy (see [ub-taxonomy.md](ub-taxonomy.md)). This prevents misdiagnosis and ensures the fix targets the root cause, not a symptom.
|
||||
3. **Prove the fix.** A fix is not done until Miri passes with full paranoia flags. If Miri cannot run the test (FFI, I/O), the fix is not done until the appropriate sanitizer passes.
|
||||
4. **Bead handoff.** Each resolved UB instance is a "bead" — a discrete, documented, proven fix. Hand it off with: the UB category, the root cause, the fix, and the Miri proof.
|
||||
|
||||
## The UB Taxonomy
|
||||
|
||||
14 categories. The full reference is in [ub-taxonomy.md](ub-taxonomy.md). Memorize the categories; classify every finding:
|
||||
|
||||
| # | Category | Miri? |
|
||||
|---|----------|-------|
|
||||
| 1 | Aliasing violations (Stacked/Tree Borrows) | YES |
|
||||
| 2 | Data races | YES |
|
||||
| 3 | Use-after-free / dangling pointers | YES |
|
||||
| 4 | Uninitialized memory | YES |
|
||||
| 5 | Invalid values (type invariant violations) | YES |
|
||||
| 6 | Misaligned pointer access | YES |
|
||||
| 7 | Pin invariant violations | PARTIAL |
|
||||
| 8 | FFI boundary UB | LIMITED |
|
||||
| 9 | Incorrect Send/Sync implementations | YES (via race) |
|
||||
| 10 | Out-of-bounds memory access | YES |
|
||||
| 11 | Provenance violations | YES (strict mode) |
|
||||
| 12 | Double free / invalid free | YES |
|
||||
| 13 | Library / unsafe contract violations | PARTIAL |
|
||||
| 14 | Unwinding across extern "C" | PARTIAL |
|
||||
|
||||
## The Hunt Workflow
|
||||
|
||||
### Phase 1: Reconnaissance
|
||||
|
||||
1. **Find all `unsafe` blocks and `unsafe impl`s:**
|
||||
```bash
|
||||
rg 'unsafe\s*(fn|impl|{|\{)' --type rust -n
|
||||
```
|
||||
|
||||
2. **Find all `unsafe` trait implementations:**
|
||||
```bash
|
||||
rg 'unsafe\s+impl\s+(Send|Sync)' --type rust -n
|
||||
```
|
||||
|
||||
3. **Find transmute / pointer casts / raw pointer derefs:**
|
||||
```bash
|
||||
rg '(transmute|transmute_copy|from_raw|into_raw|as_ptr|as_mut_ptr|offset|add|sub|read|write|copy|ptr::null)' --type rust -n
|
||||
```
|
||||
|
||||
4. **Find FFI boundaries:**
|
||||
```bash
|
||||
rg 'extern\s+"C"' --type rust -n
|
||||
```
|
||||
|
||||
5. **Count and catalog.** Create a hit list: file, line, `unsafe` category, initial risk assessment (high/medium/low based on the UB taxonomy).
|
||||
|
||||
### Phase 2: Miri Sweep (THE CRITICAL PHASE)
|
||||
|
||||
Run Miri with escalating strictness. **Do not skip any level.**
|
||||
|
||||
**Level 1 — Default (Stacked Borrows):**
|
||||
```bash
|
||||
cargo +nightly miri test 2>&1
|
||||
```
|
||||
|
||||
**Level 2 — Strict Provenance + Symbolic Alignment:**
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check -Zmiri-backtrace=full" \
|
||||
cargo +nightly miri test 2>&1
|
||||
```
|
||||
|
||||
**Level 3 — Full Paranoia (the audit standard):**
|
||||
```bash
|
||||
MIRIFLAGS="\
|
||||
-Zmiri-strict-provenance \
|
||||
-Zmiri-symbolic-alignment-check \
|
||||
-Zmiri-preemption-rate=0.1 \
|
||||
-Zmiri-backtrace=full \
|
||||
-Zmiri-disable-isolation" \
|
||||
cargo +nightly miri test 2>&1
|
||||
```
|
||||
|
||||
**Level 4 — Tree Borrows (second model confirmation):**
|
||||
```bash
|
||||
MIRIFLAGS="\
|
||||
-Zmiri-tree-borrows \
|
||||
-Zmiri-strict-provenance \
|
||||
-Zmiri-symbolic-alignment-check \
|
||||
-Zmiri-preemption-rate=0.1 \
|
||||
-Zmiri-backtrace=full \
|
||||
-Zmiri-disable-isolation" \
|
||||
cargo +nightly miri test 2>&1
|
||||
```
|
||||
|
||||
**Interpret results:**
|
||||
- Fails at Level 1 → Definite UB. Fix immediately.
|
||||
- Passes Level 1, fails Level 2 → Provenance or alignment UB. Fix.
|
||||
- Passes Levels 1-3, fails Level 4 → Tree Borrows found something Stacked Borrows missed (unusual). Investigate — may be a Tree Borrows false positive, but usually indicates fragile aliasing.
|
||||
- Passes all 4 → Miri-clean. Proceed to supplementary tools.
|
||||
|
||||
### Phase 3: Supplementary Scans
|
||||
|
||||
For code Miri cannot fully cover:
|
||||
|
||||
**Concurrent code with custom atomics:**
|
||||
```bash
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --release -- loom_tests 2>&1
|
||||
```
|
||||
|
||||
**FFI-heavy code:**
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target $(rustc -vV | rg host | awk '{print $2}') 2>&1
|
||||
```
|
||||
|
||||
**Untrusted input parsing:**
|
||||
```bash
|
||||
cargo +nightly fuzz run <target> -- -max_total_time=300 2>&1
|
||||
```
|
||||
|
||||
### Phase 4: Fix-and-Prove Loop
|
||||
|
||||
For each UB finding:
|
||||
|
||||
1. **Classify** against the 14-category taxonomy.
|
||||
2. **Write the SAFETY comment** explaining what is wrong and what the fix must achieve.
|
||||
3. **Apply the minimal fix.** Do not refactor — fix the UB and nothing else.
|
||||
4. **Run Miri (Level 3 minimum) on the specific test that triggered the UB.**
|
||||
5. **Run Miri (Level 3) on the full test suite** to check for regressions.
|
||||
6. **Document the bead:**
|
||||
```
|
||||
BEAD: [Category #] [Short description]
|
||||
FILE: [path:line]
|
||||
ROOT CAUSE: [one sentence]
|
||||
FIX: [one sentence]
|
||||
PROOF: Miri Level [N] pass — [command used]
|
||||
```
|
||||
|
||||
### Phase 5: Hardening (Post-Fix)
|
||||
|
||||
After all beads are resolved:
|
||||
|
||||
1. **Add Miri to CI** if not already present (see [miri-sanitizers-loom.md](miri-sanitizers-loom.md) for the GitHub Actions config).
|
||||
2. **Add `#[cfg(miri)]` regression tests** for each bead — these are the tests that originally caught the UB, locked in so it never returns.
|
||||
3. **Review SAFETY comments** on every remaining `unsafe` block. Each must name the specific invariant from the taxonomy.
|
||||
4. **Run the full paranoia sweep one final time** to confirm clean.
|
||||
|
||||
## Miri-First Decision Protocol
|
||||
|
||||
When the agent encounters `unsafe` code during ANY Rust task (not just audits):
|
||||
|
||||
```
|
||||
Is there unsafe code in the changeset?
|
||||
YES → Run Miri Level 1 before proceeding.
|
||||
│ Miri fails?
|
||||
│ YES → Stop. Classify. Fix. Prove. Then continue.
|
||||
│ NO → Run Miri Level 2 (strict provenance).
|
||||
│ Miri fails?
|
||||
│ YES → Stop. Classify. Fix. Prove. Then continue.
|
||||
│ NO → Proceed with the original task.
|
||||
NO → Proceed normally.
|
||||
```
|
||||
|
||||
This is not optional. **Every `unsafe` block gets Miri'd before it ships.**
|
||||
|
||||
## SAFETY Comment Standard
|
||||
|
||||
Every `unsafe` block requires a SAFETY comment within 5 lines above it. The comment must:
|
||||
|
||||
1. **Name the UB category** it could trigger (from the taxonomy).
|
||||
2. **State the invariant** that makes this safe.
|
||||
3. **Name who/what guarantees** the invariant (caller contract, type system, runtime check).
|
||||
|
||||
```rust
|
||||
// SAFETY: [Category 4 — Uninitialized Memory]
|
||||
// All N elements have been written to via `ptr::write` in the loop above.
|
||||
// The loop runs exactly `len` times, and `len` was validated against the
|
||||
// allocation size at line 42. MaybeUninit::assume_init is therefore sound.
|
||||
unsafe { buf.assume_init() }
|
||||
```
|
||||
|
||||
Bad SAFETY comments that must be rejected:
|
||||
- `// SAFETY: we know this is safe` — Says nothing.
|
||||
- `// SAFETY: this is fine because we tested it` — Testing does not prove absence of UB.
|
||||
- `// SAFETY: the caller ensures correctness` — Which invariant? What is the contract?
|
||||
- No SAFETY comment at all — Immediate failure.
|
||||
|
||||
## Audit Report Format
|
||||
|
||||
When completing a UB audit, produce a summary:
|
||||
|
||||
```markdown
|
||||
## UB Audit Report
|
||||
|
||||
**Scope:** [crate/module/file]
|
||||
**Miri version:** [output of `cargo +nightly miri --version`]
|
||||
**Date:** [date]
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Category | File:Line | Severity | Status |
|
||||
|---|----------|-----------|----------|--------|
|
||||
| 1 | Aliasing | src/buf.rs:42 | High | Fixed (Bead #1) |
|
||||
| 2 | Uninit | src/ffi.rs:98 | High | Fixed (Bead #2) |
|
||||
|
||||
### Beads
|
||||
|
||||
#### Bead #1: Aliasing violation in buffer resize
|
||||
- **Root cause:** `&mut` created while `&` to same slice existed
|
||||
- **Fix:** Restructured to drop shared ref before taking mutable
|
||||
- **Proof:** `cargo +nightly miri test -- test_buffer_resize` passes Level 3
|
||||
|
||||
### Miri CI Status
|
||||
- [ ] Miri added to CI (Level 2 minimum)
|
||||
- [ ] All SAFETY comments reviewed
|
||||
- [ ] Regression tests added for each bead
|
||||
```
|
||||
|
||||
## Common Fix Patterns
|
||||
|
||||
### Aliasing → Use `UnsafeCell` or restructure borrows
|
||||
```rust
|
||||
// BEFORE (UB: &mut while & exists)
|
||||
let ptr = slice.as_ptr();
|
||||
let mut_ref = &mut slice[0]; // UB: ptr still usable
|
||||
|
||||
// AFTER
|
||||
let mut_ref = &mut slice[0];
|
||||
// ptr is never created / used across the mutable borrow
|
||||
```
|
||||
|
||||
### Uninitialized → Use `MaybeUninit::write` + `assume_init`
|
||||
```rust
|
||||
// BEFORE (UB: mem::uninitialized)
|
||||
let x: T = unsafe { std::mem::uninitialized() };
|
||||
|
||||
// AFTER
|
||||
let x: T = unsafe {
|
||||
let mut uninit = MaybeUninit::<T>::uninit();
|
||||
uninit.write(initial_value);
|
||||
uninit.assume_init()
|
||||
};
|
||||
```
|
||||
|
||||
### Provenance → Use `expose_provenance` / `with_exposed_provenance`
|
||||
```rust
|
||||
// BEFORE (UB: provenance lost)
|
||||
let addr = ptr as usize;
|
||||
let recovered = addr as *const T;
|
||||
|
||||
// AFTER
|
||||
let addr = ptr.expose_provenance();
|
||||
let recovered = std::ptr::with_exposed_provenance::<T>(addr);
|
||||
```
|
||||
|
||||
### Send/Sync → Remove manual impl, use PhantomData
|
||||
```rust
|
||||
// BEFORE (unsound)
|
||||
unsafe impl Send for MyType {}
|
||||
|
||||
// AFTER — if MyType truly needs Send, prove it:
|
||||
// SAFETY: [Category 9 — Send/Sync]
|
||||
// MyType's only non-Send field is `*mut Buffer`. Access to the buffer
|
||||
// is guarded by `self.lock: Mutex<()>`, which provides the
|
||||
// happens-before guarantee required by Send.
|
||||
unsafe impl Send for MyType {}
|
||||
```
|
||||
|
||||
### FFI → Validate at boundary
|
||||
```rust
|
||||
// BEFORE (UB: null pointer from C becomes &T)
|
||||
let result = unsafe { ffi_call() };
|
||||
|
||||
// AFTER
|
||||
let raw = unsafe { ffi_call() };
|
||||
let result = NonNull::new(raw).ok_or(Error::NullFromFfi)?;
|
||||
```
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates when:
|
||||
- The user requests a "UB audit", "miri sweep", "unsafe audit", "soundness check", "rustonomicon audit", "race hunt"
|
||||
- The agent encounters `unsafe` code during a Rust task and needs to verify it
|
||||
- Miri reports a failure and the agent needs to classify and fix it
|
||||
- The user asks "is this sound?" about Rust code
|
||||
|
||||
**Miri is not optional. Miri is the proof. Ship nothing `unsafe` without Miri's blessing.**
|
||||
@@ -0,0 +1,411 @@
|
||||
# Miri, Sanitizers, Loom, and Fuzzing — The UB Detection Arsenal
|
||||
|
||||
Miri is the **primary weapon**. Everything else is supplementary for the gaps Miri cannot reach.
|
||||
|
||||
---
|
||||
|
||||
## Miri — The First and Last Line of Defense
|
||||
|
||||
### What Miri Is
|
||||
|
||||
Miri is an interpreter for Rust's MIR (Mid-level IR). It executes your test suite inside a virtual machine that tracks every byte of memory for validity, provenance, alignment, initialization, and aliasing. It is **deterministic** — same inputs, same result — and it can find UB that no amount of testing on real hardware will ever trigger.
|
||||
|
||||
### Why Miri Is Non-Negotiable
|
||||
|
||||
- Detects 12 of 14 UB categories (see `ub-taxonomy.md`).
|
||||
- Catches aliasing violations that compile and run correctly on every platform today but are UB that future compiler optimizations will exploit.
|
||||
- Catches data races under a configurable scheduling model.
|
||||
- Catches provenance violations that are impossible to observe on real hardware.
|
||||
- **Zero false positives** — if Miri says it is UB, it is UB. Period.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
rustup install nightly
|
||||
rustup component add miri rust-src --toolchain nightly
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
cargo +nightly miri --version
|
||||
```
|
||||
|
||||
### Running Miri
|
||||
|
||||
**Default run (Stacked Borrows, standard checks):**
|
||||
```bash
|
||||
cargo +nightly miri test
|
||||
```
|
||||
|
||||
**With nextest (recommended for projects already using nextest):**
|
||||
```bash
|
||||
cargo +nightly miri nextest run
|
||||
```
|
||||
|
||||
**Specific test:**
|
||||
```bash
|
||||
cargo +nightly miri test -- test_name
|
||||
```
|
||||
|
||||
**Run a binary:**
|
||||
```bash
|
||||
cargo +nightly miri run
|
||||
```
|
||||
|
||||
### MIRIFLAGS — The Dial-Up Knobs
|
||||
|
||||
These flags are set via the `MIRIFLAGS` environment variable. The agent should use ALL of the strictness flags during a UB audit.
|
||||
|
||||
#### Aliasing Model
|
||||
|
||||
```bash
|
||||
# Default: Stacked Borrows (strict)
|
||||
cargo +nightly miri test
|
||||
|
||||
# Tree Borrows (newer, more permissive — use as a second pass)
|
||||
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
|
||||
```
|
||||
|
||||
**Protocol:** Run Stacked Borrows first. If it fails, fix it. Then run Tree Borrows to confirm. Code that passes Stacked Borrows is sound under both models.
|
||||
|
||||
#### Strict Provenance
|
||||
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
|
||||
```
|
||||
|
||||
Catches `ptr as usize as *const T` roundtrips where provenance is lost. **Should be ON for every audit.**
|
||||
|
||||
#### Symbolic Alignment Checks
|
||||
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-symbolic-alignment-check" cargo +nightly miri test
|
||||
```
|
||||
|
||||
Catches alignment UB that happens to be aligned on your machine but is not guaranteed by the type system.
|
||||
|
||||
#### Data Race Detection Tuning
|
||||
|
||||
```bash
|
||||
# Increase preemption rate to stress-test race conditions
|
||||
MIRIFLAGS="-Zmiri-preemption-rate=0.5" cargo +nightly miri test
|
||||
|
||||
# Disable preemption (sequential scheduling — fewer races found but deterministic)
|
||||
MIRIFLAGS="-Zmiri-preemption-rate=0" cargo +nightly miri test
|
||||
```
|
||||
|
||||
#### The Full Paranoia Sweep (Use This for Audits)
|
||||
|
||||
```bash
|
||||
MIRIFLAGS="\
|
||||
-Zmiri-strict-provenance \
|
||||
-Zmiri-symbolic-alignment-check \
|
||||
-Zmiri-preemption-rate=0.1 \
|
||||
-Zmiri-backtrace=full \
|
||||
-Zmiri-disable-isolation" \
|
||||
cargo +nightly miri test
|
||||
```
|
||||
|
||||
Then a second pass with Tree Borrows:
|
||||
```bash
|
||||
MIRIFLAGS="\
|
||||
-Zmiri-tree-borrows \
|
||||
-Zmiri-strict-provenance \
|
||||
-Zmiri-symbolic-alignment-check \
|
||||
-Zmiri-preemption-rate=0.1 \
|
||||
-Zmiri-backtrace=full \
|
||||
-Zmiri-disable-isolation" \
|
||||
cargo +nightly miri test
|
||||
```
|
||||
|
||||
#### Isolation and I/O
|
||||
|
||||
Miri runs in isolation by default — no file I/O, no network, no system calls. If your tests need the filesystem:
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
|
||||
```
|
||||
|
||||
Use sparingly — isolation is a feature, not a limitation. Tests that need I/O should have a separate `#[cfg(not(miri))]` path.
|
||||
|
||||
### Miri Limitations
|
||||
|
||||
| Cannot do | Workaround |
|
||||
|-----------|-----------|
|
||||
| Execute FFI / C code | ASAN, MSAN, Valgrind |
|
||||
| Run I/O-heavy tests (default) | `-Zmiri-disable-isolation` or `#[cfg(not(miri))]` |
|
||||
| Exhaustive interleaving exploration | loom |
|
||||
| Find performance bugs | criterion, flamegraph |
|
||||
| Run inline assembly | skip with `#[cfg(not(miri))]` |
|
||||
| Test OS-specific behavior | real hardware + sanitizers |
|
||||
|
||||
### Miri in CI
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
miri:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: miri, rust-src
|
||||
- name: Miri test (Stacked Borrows + strict provenance)
|
||||
run: |
|
||||
MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check -Zmiri-backtrace=full" \
|
||||
cargo +nightly miri test
|
||||
- name: Miri test (Tree Borrows)
|
||||
run: |
|
||||
MIRIFLAGS="-Zmiri-tree-borrows -Zmiri-strict-provenance -Zmiri-symbolic-alignment-check -Zmiri-backtrace=full" \
|
||||
cargo +nightly miri test
|
||||
```
|
||||
|
||||
### Miri-Incompatible Test Gating
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)] // Miri cannot run this (FFI, I/O, inline asm)
|
||||
fn test_requires_real_hardware() {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Or conditionally compile the test body:
|
||||
#[test]
|
||||
fn test_with_miri_fallback() {
|
||||
#[cfg(miri)]
|
||||
{
|
||||
// Simplified version that avoids FFI
|
||||
}
|
||||
#[cfg(not(miri))]
|
||||
{
|
||||
// Full version with FFI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sanitizers — Where Miri Cannot Reach
|
||||
|
||||
Sanitizers are compiler instrumentation passes. They run your actual binary on real hardware with extra checks injected. Use them for FFI, I/O-heavy code, and integration tests.
|
||||
|
||||
### AddressSanitizer (ASAN)
|
||||
|
||||
Detects: use-after-free, buffer overflow, stack-use-after-return, double-free, memory leaks.
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
On macOS:
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target aarch64-apple-darwin
|
||||
```
|
||||
|
||||
### ThreadSanitizer (TSAN)
|
||||
|
||||
Detects: data races on non-atomic accesses across threads.
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
**When to use over Miri:** Integration tests involving real threads + real I/O + FFI. Miri's data-race detector is superior for pure-Rust code.
|
||||
|
||||
### MemorySanitizer (MSAN)
|
||||
|
||||
Detects: reads of uninitialized memory.
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=memory -Zsanitizer-memory-track-origins" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
**When to use over Miri:** FFI code where C/C++ may return uninitialized memory into Rust.
|
||||
|
||||
### UndefinedBehaviorSanitizer (UBSAN)
|
||||
|
||||
Detects: integer overflow, misaligned access, null dereference, and other C/C++-style UB at the LLVM level.
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="-Zsanitizer=undefined" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
### Sanitizer Limitations
|
||||
|
||||
- Require nightly + `-Zbuild-std` (rebuilds the standard library with instrumentation).
|
||||
- MSAN requires ALL dependencies (including C libs) to be instrumented — practically hard.
|
||||
- Cannot catch aliasing violations (that is Miri's domain).
|
||||
- Significant runtime overhead (2-15x slower).
|
||||
- Linux has the best support; macOS works for ASAN; Windows support is minimal.
|
||||
|
||||
---
|
||||
|
||||
## Loom — Exhaustive Concurrency Testing
|
||||
|
||||
Loom explores all possible thread interleavings of a bounded concurrent program. It is mandatory for lock-free and wait-free primitives.
|
||||
|
||||
### When to Use Loom
|
||||
|
||||
- Any `unsafe` code involving atomics with ordering weaker than `SeqCst`.
|
||||
- Custom lock implementations.
|
||||
- Lock-free queues, stacks, or other concurrent data structures.
|
||||
- Any code where you chose `Relaxed`, `Acquire`, or `Release` ordering.
|
||||
|
||||
### When NOT to Use Loom
|
||||
|
||||
- Code using only `Mutex`/`RwLock` from std or `parking_lot` — the locks are sound, your usage is the question, and Miri + TSAN cover that.
|
||||
- Async code (loom does not model async runtimes — use `tokio::test` + Miri instead).
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
loom = "0.7"
|
||||
```
|
||||
|
||||
### Loom Test Pattern
|
||||
|
||||
```rust
|
||||
#[cfg(loom)]
|
||||
mod loom_tests {
|
||||
use loom::sync::atomic::{AtomicUsize, Ordering};
|
||||
use loom::sync::Arc;
|
||||
use loom::thread;
|
||||
|
||||
#[test]
|
||||
fn concurrent_increment_is_sound() {
|
||||
loom::model(|| {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let threads: Vec<_> = (0..2).map(|_| {
|
||||
let c = counter.clone();
|
||||
thread::spawn(move || {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
}).collect();
|
||||
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Compilation for Loom
|
||||
|
||||
```rust
|
||||
#[cfg(loom)]
|
||||
use loom::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[cfg(not(loom))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
```
|
||||
|
||||
### Running Loom Tests
|
||||
|
||||
```bash
|
||||
# Loom tests only (use cfg flag)
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib -- loom_tests
|
||||
|
||||
# With release optimizations (loom is slow)
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --release -- loom_tests
|
||||
```
|
||||
|
||||
### Loom + Miri Interaction
|
||||
|
||||
Loom and Miri solve different problems:
|
||||
- **Miri** checks a single execution for UB (aliasing, validity, provenance).
|
||||
- **Loom** checks all interleavings for correctness (ordering, atomicity).
|
||||
|
||||
Run BOTH on lock-free code:
|
||||
```bash
|
||||
# Step 1: loom for interleaving correctness
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --release -- loom_tests
|
||||
|
||||
# Step 2: Miri for UB in each path
|
||||
cargo +nightly miri test -- concurrent_tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cargo-Fuzz — Property-Based UB Hunting
|
||||
|
||||
Fuzzing generates random inputs to maximize code coverage and find crashes, panics, and UB.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
cargo fuzz init
|
||||
```
|
||||
|
||||
### Fuzz Target
|
||||
|
||||
```rust
|
||||
// fuzz/fuzz_targets/parse_input.rs
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// Your parsing/deserialization/processing code here.
|
||||
// If it panics or triggers UB, the fuzzer catches it.
|
||||
let _ = my_crate::parse(data);
|
||||
});
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Run until interrupted
|
||||
cargo +nightly fuzz run parse_input
|
||||
|
||||
# Run with ASAN (catches memory bugs in unsafe code)
|
||||
cargo +nightly fuzz run parse_input -- -rss_limit_mb=4096
|
||||
|
||||
# Minimize a crashing input
|
||||
cargo +nightly fuzz tmin parse_input artifacts/parse_input/crash-xxxxx
|
||||
```
|
||||
|
||||
### Fuzz + Miri Pipeline
|
||||
|
||||
When the fuzzer finds a crashing input:
|
||||
1. Minimize it with `cargo fuzz tmin`.
|
||||
2. Add it as a regression test.
|
||||
3. Run the regression test under Miri to classify whether it is a panic (safe) or UB (must fix).
|
||||
|
||||
```bash
|
||||
# After adding the input as a test case:
|
||||
cargo +nightly miri test -- test_fuzz_regression_001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Selection Decision Tree
|
||||
|
||||
```
|
||||
Start
|
||||
│
|
||||
├── Is it pure Rust (no FFI, no I/O)?
|
||||
│ YES → Miri (full paranoia flags)
|
||||
│ │ └── Also: loom (if atomics/lock-free)
|
||||
│ │ └── Also: proptest (if parsing/serialization)
|
||||
│ │ └── Also: cargo-fuzz (if untrusted input)
|
||||
│ │
|
||||
│ NO → Does it involve FFI?
|
||||
│ YES → ASAN + MSAN on integration tests
|
||||
│ │ └── Miri on the Rust-side handling
|
||||
│ │ └── cbindgen in CI for layout verification
|
||||
│ │
|
||||
│ NO → Is it I/O-heavy?
|
||||
│ YES → TSAN for thread safety
|
||||
│ │ └── Miri with -Zmiri-disable-isolation where possible
|
||||
│ │
|
||||
│ NO → Miri (full paranoia flags)
|
||||
│
|
||||
└── Always: Miri is the default. Other tools supplement.
|
||||
```
|
||||
|
||||
## The One Rule
|
||||
|
||||
> **When in doubt, run Miri.** If Miri cannot run it, write a version it can run, and test that under Miri. Then test the real version under sanitizers. Never ship `unsafe` code that has not passed Miri.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Rust Undefined Behavior Taxonomy
|
||||
|
||||
Every category of UB the Rust compiler, Miri, and the language specification recognize. The agent must know the full surface to hunt systematically. Each entry names the UB class, its root cause, canonical trigger, Miri detection status, and the canonical fix.
|
||||
|
||||
## 1. Aliasing Violations (Stacked Borrows / Tree Borrows)
|
||||
|
||||
**Root cause:** Two pointers access the same memory in ways that violate Rust's borrowing model — even through raw pointers inside `unsafe`.
|
||||
|
||||
**Canonical triggers:**
|
||||
- Creating a `&mut T` while another `&T` or `&mut T` to the same location exists.
|
||||
- Dereferencing a raw pointer derived from a reference after that reference was invalidated (e.g., `&mut` was retaken).
|
||||
- Calling `slice::from_raw_parts_mut` on overlapping regions.
|
||||
- Interior mutability through `UnsafeCell` without going through the `UnsafeCell` API.
|
||||
- Casting `&T` to `*mut T` and writing through it (even via FFI).
|
||||
|
||||
**Miri detection:** YES — Stacked Borrows is the default model. Tree Borrows (`-Zmiri-tree-borrows`) is the newer, more permissive model. Run both:
|
||||
```bash
|
||||
cargo +nightly miri test # Stacked Borrows (stricter)
|
||||
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test # Tree Borrows (relaxed)
|
||||
```
|
||||
If code passes Tree Borrows but fails Stacked Borrows, it is *likely* sound but *possibly* relying on unspecified behavior. Fix it anyway — Stacked Borrows is the conservative bet.
|
||||
|
||||
**Fix pattern:** Use `UnsafeCell` for all interior mutability. Never cast `&T` to `*mut T`. Derive mutable pointers from `*mut T` obtained via `UnsafeCell::get()` or `addr_of_mut!()`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Races
|
||||
|
||||
**Root cause:** Two threads access the same non-atomic memory location, at least one is a write, and there is no happens-before ordering between them.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `unsafe impl Send for T` on a type containing `*mut U` without synchronization.
|
||||
- `unsafe impl Sync for T` on a type containing `Cell<T>` or `UnsafeCell<T>` without a lock.
|
||||
- Using `std::ptr::write` from multiple threads to the same allocation.
|
||||
- Shared `&T` where `T` has interior mutability but no atomic/lock guard.
|
||||
|
||||
**Miri detection:** YES — Miri's data-race detector is on by default. It detects races on non-atomic accesses. For **preemptive scheduling** stress, use:
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-preemption-rate=0.1" cargo +nightly miri test
|
||||
```
|
||||
|
||||
**Complementary tools:** `loom` for exhaustive interleaving exploration on lock-free algorithms. ThreadSanitizer (TSAN) for integration tests Miri cannot run (I/O, FFI).
|
||||
|
||||
**Fix pattern:** Wrap in `Mutex`/`RwLock`/`AtomicXxx`. Never `unsafe impl Sync` unless you can name the synchronization primitive guarding every mutable field.
|
||||
|
||||
---
|
||||
|
||||
## 3. Use After Free / Dangling Pointers
|
||||
|
||||
**Root cause:** A pointer or reference outlives the allocation it points to.
|
||||
|
||||
**Canonical triggers:**
|
||||
- Returning a reference to a local variable (compiler catches most, but raw pointers escape).
|
||||
- `Box::into_raw` → manual `Box::from_raw` with wrong lifetime.
|
||||
- `Vec` reallocation invalidating raw pointers obtained from `as_ptr()` / `as_mut_ptr()`.
|
||||
- `Pin<Box<T>>` unpinned and moved after self-referential pointers were set up.
|
||||
|
||||
**Miri detection:** YES — allocation tracking catches use-after-free on the exact operation.
|
||||
|
||||
**Fix pattern:** Borrow checker for references. For raw pointers: tie pointer validity to an explicit lifetime via a `PhantomData<&'a T>` in the wrapper, or use arena allocation (`bumpalo`) so all pointers share one lifetime.
|
||||
|
||||
---
|
||||
|
||||
## 4. Uninitialized Memory
|
||||
|
||||
**Root cause:** Reading a value from memory that was never written to.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `MaybeUninit::assume_init()` before all bytes are written.
|
||||
- `mem::uninitialized()` (deprecated, still compiles).
|
||||
- `alloc::alloc(layout)` returns uninitialized memory — reading it before writing is UB.
|
||||
- Padding bytes in structs read via `transmute` or raw pointer casts.
|
||||
- `read_unaligned` on uninitialized memory.
|
||||
|
||||
**Miri detection:** YES — tracks initialization state per byte. Catches partial-init structs, padding reads, and premature `assume_init`.
|
||||
|
||||
**Fix pattern:** Use `MaybeUninit::zeroed()` when zero-init is acceptable. Write every field before calling `assume_init()`. Use `MaybeUninit::write()` instead of raw pointer writes. Never `transmute` structs with padding unless you zeroed the padding.
|
||||
|
||||
---
|
||||
|
||||
## 5. Invalid Values (Type Invariant Violations)
|
||||
|
||||
**Root cause:** Producing a value that violates the type's validity invariant.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `bool` not 0 or 1.
|
||||
- `char` outside Unicode scalar range.
|
||||
- Enum discriminant not matching any variant.
|
||||
- `NonZeroU32` containing 0.
|
||||
- `&T` or `&mut T` that is null or dangling.
|
||||
- `str` containing non-UTF-8 bytes.
|
||||
- `fn` pointer that is null.
|
||||
|
||||
**Miri detection:** YES — validity checks are on by default. Extra strictness:
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
|
||||
```
|
||||
|
||||
**Fix pattern:** Validate before transmuting. Use `TryFrom` at boundaries. Never `transmute` to enum types — use a checked conversion function.
|
||||
|
||||
---
|
||||
|
||||
## 6. Misaligned Pointer Access
|
||||
|
||||
**Root cause:** Dereferencing a pointer that is not aligned to the type's required alignment.
|
||||
|
||||
**Canonical triggers:**
|
||||
- Casting `*const u8` to `*const u64` and dereferencing (alignment goes from 1 to 8).
|
||||
- `#[repr(packed)]` struct field references (the compiler warns, but raw pointers bypass the warning).
|
||||
- Network buffer parsing where offsets are arbitrary.
|
||||
|
||||
**Miri detection:** YES — immediate trap on misaligned read/write.
|
||||
|
||||
**Fix pattern:** Use `read_unaligned` / `write_unaligned` for packed data. Use `bytemuck` or `zerocopy` for safe reinterpretation with alignment checks.
|
||||
|
||||
---
|
||||
|
||||
## 7. Violating `Pin` Invariants
|
||||
|
||||
**Root cause:** Moving a value that was pinned and relied on its address stability (self-referential types, intrusive linked lists).
|
||||
|
||||
**Canonical triggers:**
|
||||
- `mem::swap` on a `Pin<&mut T>` after `unsafe` deref.
|
||||
- Implementing `Unpin` for a type that contains self-referential pointers.
|
||||
- Manually calling `Pin::new_unchecked` on a movable allocation.
|
||||
|
||||
**Miri detection:** PARTIAL — Miri detects the resulting aliasing/use-after-free if the self-referential pointer is actually used. It does not detect "Pin contract violated but pointer was never dereferenced."
|
||||
|
||||
**Fix pattern:** Never `impl Unpin` for self-referential types. Use `pin_project` or `pin_project_lite` for safe pin projections. Review every `Pin::new_unchecked` call.
|
||||
|
||||
---
|
||||
|
||||
## 8. FFI Boundary UB
|
||||
|
||||
**Root cause:** Mismatch between Rust's ABI expectations and the foreign code's actual behavior.
|
||||
|
||||
**Canonical triggers:**
|
||||
- C function returning uninitialized memory into a Rust `&T`.
|
||||
- Wrong `#[repr(C)]` layout (padding differs between platforms).
|
||||
- Passing a Rust `enum` to C without `#[repr(C)]` or `#[repr(i32)]`.
|
||||
- Null pointer passed where C expects non-null (and Rust wraps it in `&T`).
|
||||
- C code writing to Rust-owned memory through a pointer Rust considers immutable.
|
||||
- Forgetting to mark FFI functions as `unsafe extern "C"`.
|
||||
- longjmp/setjmp across Rust frames (unwinding UB).
|
||||
|
||||
**Miri detection:** LIMITED — Miri cannot execute foreign code. It detects UB in the Rust-side handling of FFI return values.
|
||||
|
||||
**Complementary tools:** AddressSanitizer (ASAN), MemorySanitizer (MSAN) for detecting actual FFI-side corruption. Valgrind as a last resort.
|
||||
|
||||
**Fix pattern:** Validate every FFI return at the boundary. Use `Option<NonNull<T>>` for nullable pointers. Use `CStr`/`CString` for strings. Add `cbindgen` to CI to verify layout agreement. Wrap every FFI call in a safe Rust function that checks preconditions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Incorrect `Send` / `Sync` Implementations
|
||||
|
||||
**Root cause:** Manually implementing `Send` or `Sync` for a type that does not actually uphold the required invariant.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `unsafe impl Send for Wrapper(*mut T)` when `T` is not `Send`.
|
||||
- `unsafe impl Sync for Wrapper(UnsafeCell<T>)` without a lock, atomic, or other synchronization.
|
||||
- Types containing `Rc<T>` with a manual `Send` impl (Rc is explicitly !Send).
|
||||
|
||||
**Miri detection:** YES for the *resulting* data race if exercised. Miri's data-race detector will fire when two threads access the same location unsynchronized.
|
||||
|
||||
**Fix pattern:** Never manually implement `Send`/`Sync` unless you can write a SAFETY proof naming the synchronization mechanism. Use `PhantomData<*const ()>` to opt-out of auto-`Send`/`Sync` when in doubt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out-of-Bounds Memory Access
|
||||
|
||||
**Root cause:** Pointer arithmetic or indexing that escapes the allocation.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `ptr.offset(n)` where `n` exceeds the allocation size.
|
||||
- `slice::from_raw_parts(ptr, len)` where `len` is too large.
|
||||
- Off-by-one in manual buffer management.
|
||||
- Integer overflow in size calculations leading to undersized allocation.
|
||||
|
||||
**Miri detection:** YES — allocation-precise bounds checking.
|
||||
|
||||
**Fix pattern:** Use checked arithmetic (`checked_add`, `checked_mul`) for size calculations. Use `slice::from_raw_parts` only with validated lengths. Prefer safe indexing (`get()`, iterators) over raw pointer arithmetic.
|
||||
|
||||
---
|
||||
|
||||
## 11. Provenance Violations
|
||||
|
||||
**Root cause:** Using a pointer whose provenance does not grant access to the target memory, even if the address is numerically correct.
|
||||
|
||||
**Canonical triggers:**
|
||||
- Casting an integer to a pointer and dereferencing it (`addr as *const T`).
|
||||
- Roundtripping a pointer through `usize` and back (`ptr as usize as *const T`) — the provenance is lost.
|
||||
- Using `ptr::from_exposed_addr` without a corresponding `ptr.expose_provenance()`.
|
||||
|
||||
**Miri detection:** YES with strict provenance:
|
||||
```bash
|
||||
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
|
||||
```
|
||||
|
||||
**Fix pattern:** Use `ptr::with_exposed_provenance` / `ptr.expose_provenance()` for legitimate int-to-ptr roundtrips. Avoid `as usize as *const T` entirely. Use `sptr` crate for provenance-safe pointer manipulation on stable.
|
||||
|
||||
---
|
||||
|
||||
## 12. Double Free / Invalid Free
|
||||
|
||||
**Root cause:** Freeing the same allocation twice, or freeing memory not obtained from the allocator.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `Box::from_raw` called twice on the same pointer.
|
||||
- Manual `dealloc` on a pointer already freed.
|
||||
- `ManuallyDrop` dropped explicitly then the outer type also drops it.
|
||||
|
||||
**Miri detection:** YES — immediate trap.
|
||||
|
||||
**Fix pattern:** Enforce single ownership via RAII. Use `ManuallyDrop` with extreme care — document who is responsible for the drop. Never clone a raw pointer and `Box::from_raw` both copies.
|
||||
|
||||
---
|
||||
|
||||
## 13. Library / Unsafe Contract Violations
|
||||
|
||||
**Root cause:** Violating the documented safety invariant of a safe or unsafe API, where the library author relied on the invariant for soundness.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `Vec::set_len(n)` where the first `n` elements are not initialized.
|
||||
- `String::from_utf8_unchecked` on non-UTF-8 bytes.
|
||||
- `HashMap` key mutated after insertion (violates hash invariant — not UB per se, but unsound and Miri may detect downstream effects).
|
||||
- `BTreeMap` key with broken `Ord` impl (the standard library assumes a total order).
|
||||
|
||||
**Miri detection:** DEPENDS — Miri catches the downstream UB (e.g., reading uninitialized bytes from a `Vec` with inflated len). It does not catch "you violated the documented contract" if no memory-level UB results.
|
||||
|
||||
**Fix pattern:** Read the `# Safety` section of every `unsafe fn` you call. Document the invariant in your SAFETY comment. When in doubt, use the safe API and pay the cost.
|
||||
|
||||
---
|
||||
|
||||
## 14. Unwinding Across `extern "C"` Boundaries
|
||||
|
||||
**Root cause:** A Rust panic unwinding through a frame that uses the C calling convention.
|
||||
|
||||
**Canonical triggers:**
|
||||
- `panic!()` inside a `#[no_mangle] extern "C" fn` callback passed to C code.
|
||||
- `unwrap()` inside FFI callbacks.
|
||||
|
||||
**Miri detection:** PARTIAL — Miri does not model foreign unwinding, but it can detect the immediate UB if the panic reaches the FFI boundary.
|
||||
|
||||
**Fix pattern:** Use `std::panic::catch_unwind` at every FFI entry point. Mark FFI callbacks as `extern "C-unwind"` when panic propagation is intentional (nightly). Prefer returning `Result`-like error codes from FFI callbacks.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| # | Category | Miri Detects? | Complementary Tool |
|
||||
|---|----------|--------------|-------------------|
|
||||
| 1 | Aliasing (Stacked/Tree Borrows) | YES | — |
|
||||
| 2 | Data races | YES | loom, TSAN |
|
||||
| 3 | Use-after-free / dangling | YES | ASAN |
|
||||
| 4 | Uninitialized memory | YES | MSAN |
|
||||
| 5 | Invalid values | YES | — |
|
||||
| 6 | Misaligned access | YES | UBSAN |
|
||||
| 7 | Pin invariant violation | PARTIAL | manual review |
|
||||
| 8 | FFI boundary UB | LIMITED | ASAN, MSAN, Valgrind |
|
||||
| 9 | Incorrect Send/Sync | YES (via race) | loom |
|
||||
| 10 | Out-of-bounds access | YES | ASAN |
|
||||
| 11 | Provenance violations | YES (strict mode) | — |
|
||||
| 12 | Double free | YES | ASAN |
|
||||
| 13 | Library contract violations | PARTIAL | proptest, fuzzing |
|
||||
| 14 | Unwinding across FFI | PARTIAL | — |
|
||||
|
||||
## Miri Coverage Assessment
|
||||
|
||||
Miri catches categories 1-6, 9-12 with high confidence. Categories 7, 8, 13, 14 require supplementary tools or manual audit. **Miri is the single highest-leverage tool** — it should run on every PR that touches `unsafe`, and ideally on the full test suite regularly.
|
||||
@@ -0,0 +1,317 @@
|
||||
# Rust Programmer
|
||||
|
||||
Production Rust in 2026. **Explicit allocation, compile-time proof, zero hidden cost.** Type-state-first, unsafe-banished-by-default, agent-proof.
|
||||
|
||||
## Identity — What Kind of Rust You Write
|
||||
|
||||
You write Rust that looks like a Zig programmer designed it and a Rust compiler enforces it. Every allocation is visible. Every cost is explicit. Every invariant is encoded in the type system. Every cleanup is deterministic. The borrow checker, lifetime analysis, trait bounds, and `miri` then guarantee what Zig leaves to discipline.
|
||||
|
||||
**Five pillars, every file, no exceptions:**
|
||||
|
||||
| Pillar | Default Behavior | Reference |
|
||||
|---|---|---|
|
||||
| **Explicit allocation** | Arena for hot paths, `&[T]`/`Cow` over `Vec`/`String` in signatures, `try_*` when allocation can fail | [zero-cost-safety.md §1](zero-cost-safety.md) |
|
||||
| **Compile-time proof** | `const fn` everything const-eligible, `const { assert!(...) }` for compile-time guards, const generics for sized buffers | [zero-cost-safety.md §2](zero-cost-safety.md) |
|
||||
| **Zero hidden cost** | Slice-based APIs where caller owns memory, no hidden `.clone()`/`.to_string()`, `Cow` to defer allocation | [zero-cost-safety.md §3](zero-cost-safety.md) |
|
||||
| **Type-encoded invariants** | Newtype wrappers for every semantic unit, type-state for state machines, branded IDs | [type-state.md](type-state.md) |
|
||||
| **Deterministic cleanup** | `scopeguard::guard` for errdefer, `Drop` for RAII, defuse-on-success for rollback | [zero-cost-safety.md §5](zero-cost-safety.md) |
|
||||
|
||||
The two highest-leverage tools Rust gives a coding agent:
|
||||
|
||||
1. **Bounded polymorphism** (traits). Real, machine-checked, composable constraints.
|
||||
2. **Newtype-as-coordinate-space.** `Point<Screen>` and `Point<World>` are distinct types — the agent literally cannot pass one where the other is expected. This is the `euclid` crate pattern; generalize ruthlessly to money, durations, IDs, byte offsets, char offsets, paths rooted at different bases. Full patterns → [type-state.md](type-state.md).
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules (Every `.rs` File)
|
||||
|
||||
### 1. No `unwrap()`, No `expect()` Outside Tests
|
||||
|
||||
```rust
|
||||
// WRONG
|
||||
let val = map.get("key").unwrap();
|
||||
|
||||
// RIGHT — propagate or provide context
|
||||
let val = map.get("key").context("missing 'key' in config")?;
|
||||
```
|
||||
|
||||
Typed errors for libraries ([thiserror](https://docs.rs/thiserror)), ad-hoc errors for binaries ([anyhow](https://docs.rs/anyhow) / [color-eyre](https://docs.rs/color-eyre)). Full stack → [libraries.md](libraries.md).
|
||||
|
||||
### 2. No `unsafe` Without Miri Proof
|
||||
|
||||
If `unsafe` is unavoidable, you have miri. Run it. Always. **Load [`../rust-ub/README.md`](../rust-ub/README.md) plus every file under [`../rust-ub/`](../rust-ub/)** for the full UB taxonomy, Miri escalation protocol (4 strictness levels), and the fix-and-prove workflow. Every `unsafe` block needs the three components from [unsafe-discipline.md](unsafe-discipline.md): safe wrapper, `// SAFETY:` comment, miri test.
|
||||
|
||||
```bash
|
||||
cargo +nightly miri nextest run
|
||||
```
|
||||
|
||||
### 3. Explicit Allocation — Arena by Default in Hot Paths
|
||||
|
||||
**Do not scatter `Box::new()` / `Vec::new()` across hot loops.** Use arena allocation to make allocation scope visible and bulk-freeable. Full recipes → [zero-cost-safety.md §1](zero-cost-safety.md).
|
||||
|
||||
```rust
|
||||
use bumpalo::Bump;
|
||||
|
||||
fn parse_frame<'a>(arena: &'a Bump, raw: &[u8]) -> Frame<'a> {
|
||||
let header = arena.alloc(parse_header(raw));
|
||||
let payload = arena.alloc_slice_copy(&raw[HEADER_LEN..]);
|
||||
Frame { header, payload }
|
||||
}
|
||||
// Caller owns arena. Caller decides when memory dies. Zero individual frees.
|
||||
```
|
||||
|
||||
When arena is overkill (simple CLI, one-shot allocation), `Vec`/`String` are fine — but **function signatures still prefer borrows**:
|
||||
|
||||
```rust
|
||||
// WRONG — forces caller to allocate
|
||||
fn process(input: String) -> String { ... }
|
||||
|
||||
// RIGHT — caller chooses allocation strategy
|
||||
fn process(input: &str) -> Cow<'_, str> { ... }
|
||||
|
||||
// BEST for hot paths — zero allocation, caller provides buffer
|
||||
fn process(input: &[u8], output: &mut [u8]) -> usize { ... }
|
||||
```
|
||||
|
||||
### 4. Compile-Time First — const fn Everything Const-Eligible
|
||||
|
||||
If a function CAN be `const fn`, it MUST be `const fn`. Full recipes → [zero-cost-safety.md §2](zero-cost-safety.md).
|
||||
|
||||
```rust
|
||||
// Lookup tables computed at compile time — zero runtime cost
|
||||
const CRC_TABLE: [u32; 256] = {
|
||||
let mut table = [0u32; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut crc = i as u32;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB88320 } else { crc >> 1 };
|
||||
j += 1;
|
||||
}
|
||||
table[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
// Compile-time assertions — catch violations at build time, not runtime
|
||||
const { assert!(std::mem::size_of::<Header>() == 12, "Header must be 12 bytes") };
|
||||
```
|
||||
|
||||
Use `const generics` for stack-allocated buffers with compile-time size:
|
||||
|
||||
```rust
|
||||
struct RingBuffer<T, const N: usize> {
|
||||
data: [MaybeUninit<T>; N],
|
||||
head: usize,
|
||||
len: usize,
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Scope Guards — Deterministic Cleanup on Every Path
|
||||
|
||||
Zig's `errdefer` in Rust. Full recipes → [zero-cost-safety.md §5](zero-cost-safety.md).
|
||||
|
||||
```rust
|
||||
use scopeguard::guard;
|
||||
|
||||
fn deploy(artifact: &Path) -> Result<(), DeployError> {
|
||||
let backup = snapshot_current()?;
|
||||
// errdefer: restore on failure
|
||||
let rollback = guard(backup, |b| { let _ = restore(&b); });
|
||||
|
||||
upload(artifact)?;
|
||||
health_check()?;
|
||||
|
||||
// Success: defuse the guard
|
||||
scopeguard::ScopeGuard::into_inner(rollback);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Bit-Level Layout — zerocopy for Wire Formats
|
||||
|
||||
Never hand-write `transmute` or pointer casts for parsing binary data. Full recipes → [zero-cost-safety.md §4](zero-cost-safety.md).
|
||||
|
||||
```rust
|
||||
use zerocopy::{FromBytes, IntoBytes, KnownLayout, Immutable};
|
||||
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable)]
|
||||
#[repr(C)]
|
||||
struct PacketHeader {
|
||||
magic: [u8; 4],
|
||||
version: u8,
|
||||
flags: u8,
|
||||
length: [u8; 2], // use byte array for packed fields, decode via from_le_bytes
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Exhaustive Match — No Wildcard on Enums You Control
|
||||
|
||||
```rust
|
||||
// WRONG — silently ignores new variants
|
||||
match status {
|
||||
Status::Ok => handle_ok(),
|
||||
_ => handle_error(),
|
||||
}
|
||||
|
||||
// RIGHT — compiler forces update when variants change
|
||||
match status {
|
||||
Status::Ok => handle_ok(),
|
||||
Status::NotFound => handle_not_found(),
|
||||
Status::Timeout => handle_timeout(),
|
||||
}
|
||||
```
|
||||
|
||||
For `#[non_exhaustive]` enums from external crates, the wildcard `_` is required — but add a `tracing::warn!` in the catch-all so you notice when new variants appear.
|
||||
|
||||
### 8. Type-State Over Runtime Checks
|
||||
|
||||
Never `if self.state == State::Validated`. Encode states as distinct types so the compiler refuses invalid transitions. Full patterns → [type-state.md](type-state.md).
|
||||
|
||||
```rust
|
||||
struct Order<S: OrderState> { data: OrderData, _state: PhantomData<S> }
|
||||
struct Draft;
|
||||
struct Validated;
|
||||
struct Paid;
|
||||
|
||||
impl Order<Draft> {
|
||||
fn validate(self) -> Result<Order<Validated>, ValidationError> { ... }
|
||||
}
|
||||
impl Order<Validated> {
|
||||
fn pay(self, payment: Payment) -> Result<Order<Paid>, PaymentError> { ... }
|
||||
}
|
||||
// Order<Draft> has no .pay() method. Compiler enforces the workflow.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Library Defaults
|
||||
|
||||
Full decision tree with rationale and code snippets → [libraries.md](libraries.md).
|
||||
|
||||
| Category | Crate | Why |
|
||||
|---|---|---|
|
||||
| Async runtime | `tokio` | Ecosystem standard. Patterns → [async-tokio.md](async-tokio.md) |
|
||||
| HTTP server | `axum` + `tower` | Type-safe extractors, tower middleware. Stack → [axum-stack.md](axum-stack.md) |
|
||||
| CLI | `clap` derive + `color-eyre` | Typed args, beautiful errors. Stack → [clap-stack.md](clap-stack.md) |
|
||||
| Serialization | `serde` + `serde_json` | Non-negotiable for any boundary type |
|
||||
| Error (library) | `thiserror` | Derive `Error` with zero boilerplate |
|
||||
| Error (binary) | `anyhow` / `color-eyre` | Context-rich ad-hoc errors |
|
||||
| Database | `sqlx` (compile-time checked) | No runtime SQL surprises |
|
||||
| Arena alloc | `bumpalo` / `typed-arena` | Explicit allocation scope. Patterns → [zero-cost-safety.md §1](zero-cost-safety.md) |
|
||||
| Zero-copy parse | `zerocopy` | Safe binary parsing, no transmute. Patterns → [zero-cost-safety.md §4](zero-cost-safety.md) |
|
||||
| Scope guard | `scopeguard` | errdefer/defer. Patterns → [zero-cost-safety.md §5](zero-cost-safety.md) |
|
||||
| Stack collections | `smallvec` / `arrayvec` / `tinyvec` | Stack-first, heap-spillover. Patterns → [zero-cost-safety.md §3](zero-cost-safety.md) |
|
||||
| Bitfield | `bitfield` / `modular-bitfield` | Bit-packed flags. Patterns → [zero-cost-safety.md §4](zero-cost-safety.md) |
|
||||
| Testing | `proptest` + `insta` | Property + snapshot tests. Patterns → [proptest-insta.md](proptest-insta.md) |
|
||||
| Concurrency | `tokio::sync` / `parking_lot` | Channel-first, lock-second. Patterns → [concurrency.md](concurrency.md) |
|
||||
|
||||
---
|
||||
|
||||
## Cargo Strict Configuration
|
||||
|
||||
Every new project gets the strict lint config from [cargo-strict.md](cargo-strict.md). The non-negotiable CI gate:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check && \
|
||||
cargo clippy --all-targets --all-features -- -D warnings && \
|
||||
cargo nextest run && \
|
||||
cargo +nightly miri nextest run # when unsafe is involved
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist (Post-Write, Every PR)
|
||||
|
||||
Run through this list after writing any Rust code. Every item links to its recipe.
|
||||
|
||||
| # | Check | Fix Reference |
|
||||
|---|---|---|
|
||||
| 1 | Every function signature prefers `&[T]`/`&str`/`Cow` over owned types | [zero-cost-safety.md §3](zero-cost-safety.md) |
|
||||
| 2 | Hot-path allocations use arena (`bumpalo`) not scattered `Box`/`Vec` | [zero-cost-safety.md §1](zero-cost-safety.md) |
|
||||
| 3 | Const-eligible functions are `const fn` | [zero-cost-safety.md §2](zero-cost-safety.md) |
|
||||
| 4 | Lookup tables / config constants computed at compile time | [zero-cost-safety.md §2](zero-cost-safety.md) |
|
||||
| 5 | Binary format parsing uses `zerocopy`, not `transmute` | [zero-cost-safety.md §4](zero-cost-safety.md) |
|
||||
| 6 | Cleanup logic uses `scopeguard` or `Drop`, never manual `if err` cleanup | [zero-cost-safety.md §5](zero-cost-safety.md) |
|
||||
| 7 | Distinct semantic units are newtypes, not primitive aliases | [type-state.md](type-state.md) |
|
||||
| 8 | State machines use type-state, not runtime `if state ==` | [type-state.md](type-state.md) |
|
||||
| 9 | No `unwrap()`/`expect()` outside `#[cfg(test)]` | [libraries.md](libraries.md) |
|
||||
| 10 | Every `unsafe` has SAFETY comment + miri test | [unsafe-discipline.md](unsafe-discipline.md), [../rust-ub/](../rust-ub/) |
|
||||
| 11 | Match on owned enums is exhaustive (no `_ =>`) | This file §7 |
|
||||
| 12 | Clippy pedantic passes with zero warnings | [cargo-strict.md](cargo-strict.md) |
|
||||
| 13 | Property tests exist for any function with a nontrivial domain | [proptest-insta.md](proptest-insta.md) |
|
||||
| 14 | Concurrency uses channels first, locks second, atomics last | [concurrency.md](concurrency.md) |
|
||||
| 15 | Async code uses `JoinSet` for structured concurrency | [async-tokio.md](async-tokio.md) |
|
||||
|
||||
---
|
||||
|
||||
## Default Cargo.toml Dependencies — Zero-Cost Safety Stack
|
||||
|
||||
Every new project starts with these alongside the standard deps from [cargo-strict.md](cargo-strict.md):
|
||||
|
||||
```toml
|
||||
# Zero-cost safety stack
|
||||
bumpalo = { version = "3", features = ["collections"] }
|
||||
scopeguard = "1"
|
||||
smallvec = { version = "1", features = ["union", "const_generics"] }
|
||||
zerocopy = { version = "0.8", features = ["derive"] }
|
||||
|
||||
# Add when needed:
|
||||
# typed-arena = "2" # homogeneous arena
|
||||
# arrayvec = "0.7" # fixed-capacity stack vec
|
||||
# tinyvec = { version = "1", features = ["alloc"] }
|
||||
# bitfield = "0.17" # bit-packed flags
|
||||
# modular-bitfield = "0.11" # richer bitfield API
|
||||
# bytemuck = { version = "1", features = ["derive"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Index
|
||||
|
||||
| File | When to Load |
|
||||
|---|---|
|
||||
| [zero-cost-safety.md](zero-cost-safety.md) | Arena, allocator, const fn, comptime, zero-alloc, bitfield, repr, scopeguard, errdefer, Zig-like patterns |
|
||||
| [type-state.md](type-state.md) | Newtype wrappers, type-state machines, branded IDs, phantom types |
|
||||
| [unsafe-discipline.md](unsafe-discipline.md) | Any `unsafe` block — SAFETY comments, safe wrappers, miri proof |
|
||||
| [libraries.md](libraries.md) | Library selection, crate decision tree, dependency audit |
|
||||
| [cargo-strict.md](cargo-strict.md) | Project bootstrap, lint config, CI gate commands |
|
||||
| [async-tokio.md](async-tokio.md) | Async runtime, spawning, cancellation, `JoinSet`, `select!` |
|
||||
| [axum-stack.md](axum-stack.md) | HTTP services — axum + sqlx + tower + tracing |
|
||||
| [clap-stack.md](clap-stack.md) | CLI tools — clap derive + color-eyre + indicatif |
|
||||
| [concurrency.md](concurrency.md) | Locks, atomics, channels, loom model checker |
|
||||
| [proptest-insta.md](proptest-insta.md) | Property tests, snapshot tests, round-trip invariants |
|
||||
| [one-liners.md](one-liners.md) | `rust-script` one-liners, disposable scripts, inline deps |
|
||||
| [../rust-ub/README.md](../rust-ub/README.md) | UB hunting — miri escalation, sanitizers, fuzzing |
|
||||
| [../rust-ub/ub-taxonomy.md](../rust-ub/ub-taxonomy.md) | 14-category UB taxonomy with detection status |
|
||||
| [../rust-ub/miri-sanitizers-loom.md](../rust-ub/miri-sanitizers-loom.md) | Miri flags, ASAN/TSAN/MSAN, loom, cargo-fuzz |
|
||||
|
||||
---
|
||||
|
||||
## The Shape of Every Function
|
||||
|
||||
```rust
|
||||
/// One-line doc explaining WHAT, not HOW.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `FooError::Bar` when the input is invalid.
|
||||
const fn frobnicate<'a>(
|
||||
arena: &'a Bump, // explicit allocator when arena is in play
|
||||
input: &[u8], // borrow, not owned
|
||||
output: &mut [u8], // caller-provided buffer
|
||||
) -> Result<&'a Frob, FrobError> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Why this shape:** the caller sees every cost. Allocation scope is the arena's lifetime. Input is borrowed. Output buffer is caller-owned. Error is typed. The compiler enforces all of it.
|
||||
|
||||
---
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates whenever you are writing or modifying any `.rs` file or `Cargo.toml`. One-off scripts get the strict treatment too — `rust-script` + the same lints, the same gates. Details → [one-liners.md](one-liners.md).
|
||||
|
||||
**The promise:** production hygiene with throwaway ergonomics. Explicit allocation, compile-time proof, zero hidden cost, and **agent-proof safety at any volume**.
|
||||
@@ -0,0 +1,299 @@
|
||||
# Async with Tokio
|
||||
|
||||
Structured concurrency, cancellation, blocking-work isolation, channel selection. The patterns the agent should reach for by default.
|
||||
|
||||
## Runtime selection
|
||||
|
||||
```rust
|
||||
// Default for services and CLIs that do real work
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn main() -> anyhow::Result<()> { ... }
|
||||
|
||||
// For tiny CLIs or wasm where you measured single-thread is enough
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> { ... }
|
||||
```
|
||||
|
||||
Pick worker count explicitly. The default (`num_cpus`) is fine for servers; for desktop tools you usually want 2-4.
|
||||
|
||||
## Spawning
|
||||
|
||||
`tokio::spawn` returns a `JoinHandle<T>`. The future runs to completion even if the handle is dropped (detached). To enforce structured concurrency, use `JoinSet`:
|
||||
|
||||
```rust
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
let mut set = JoinSet::new();
|
||||
for url in urls {
|
||||
let client = client.clone();
|
||||
set.spawn(async move { fetch(&client, &url).await });
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(joined) = set.join_next().await {
|
||||
match joined {
|
||||
Ok(Ok(body)) => results.push(body),
|
||||
Ok(Err(error)) => tracing::warn!(%error, "fetch failed"),
|
||||
Err(panicked) if panicked.is_panic() => {
|
||||
tracing::error!(?panicked, "worker panicked");
|
||||
// Choose: re-raise, or continue with degraded result set.
|
||||
}
|
||||
Err(other) => tracing::error!(?other, "worker join error"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`JoinSet`:
|
||||
- Knows when all spawned tasks finish.
|
||||
- Dropping the set aborts every still-running task.
|
||||
- Lets you handle failures one by one rather than all-or-nothing.
|
||||
|
||||
For wait-for-all semantics with one type, `join!`:
|
||||
|
||||
```rust
|
||||
let (a, b, c) = tokio::join!(load_a(), load_b(), load_c());
|
||||
let a = a?; let b = b?; let c = c?;
|
||||
```
|
||||
|
||||
For first-of-many, `select!`:
|
||||
|
||||
```rust
|
||||
tokio::select! {
|
||||
biased; // bias to top-to-bottom checking when ordering matters
|
||||
_ = shutdown.recv() => {
|
||||
tracing::info!("shutdown signal");
|
||||
return Ok(());
|
||||
}
|
||||
request = listener.accept() => {
|
||||
handle_request(request?).await?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `biased`, branches are polled in random order each iteration (good for fairness). Use `biased` only when you need deterministic priority (shutdown signal first, etc).
|
||||
|
||||
## Cancellation
|
||||
|
||||
A future is cancelled when it is dropped (e.g., the `select!` arm wins another branch). **Always think: if this future is dropped mid-await, what state is left behind?**
|
||||
|
||||
Cancel-safe futures (you can drop without lasting effect):
|
||||
- `recv()` on channels
|
||||
- `accept()` on listeners
|
||||
- `wait_for` on `watch::Receiver`
|
||||
- `read_buf`/`write_all` on streams **only when buffers are owned by the future**, otherwise no
|
||||
|
||||
Cancel-unsafe futures (dropping mid-way leaves partial state):
|
||||
- Manual `read_exact` into an external buffer
|
||||
- Custom futures that perform partial side effects before suspending
|
||||
|
||||
If a function is cancel-unsafe, document it in a rustdoc `# Cancel Safety` section.
|
||||
|
||||
To explicitly opt out of cancellation, use `tokio_util::sync::CancellationToken`:
|
||||
|
||||
```rust
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
let token = CancellationToken::new();
|
||||
let child = token.child_token();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = child.cancelled() => { /* clean up */ }
|
||||
result = work() => { /* normal */ }
|
||||
}
|
||||
});
|
||||
// later
|
||||
token.cancel();
|
||||
```
|
||||
|
||||
Pass child tokens down the call tree so the whole tree can be cancelled together.
|
||||
|
||||
## Timeouts
|
||||
|
||||
```rust
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
match timeout(Duration::from_secs(5), fetch(url)).await {
|
||||
Ok(Ok(body)) => Ok(body),
|
||||
Ok(Err(error)) => Err(error.into()),
|
||||
Err(_elapsed) => Err(anyhow::anyhow!("timed out fetching {url}")),
|
||||
}
|
||||
```
|
||||
|
||||
Set timeouts on every external I/O boundary. Defaults of "wait forever" are bugs.
|
||||
|
||||
## Blocking work
|
||||
|
||||
NEVER block inside an async task. Symptoms: deadlock, every future stalled, latency cliffs.
|
||||
|
||||
Heavy CPU or sync I/O → `spawn_blocking`:
|
||||
|
||||
```rust
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
// CPU-bound: parsing, hashing, image processing
|
||||
// Or sync I/O: rusqlite, OS APIs without async wrappers
|
||||
expensive_pure_computation()
|
||||
}).await?;
|
||||
```
|
||||
|
||||
Long-running blocking jobs (more than ~1 second of CPU) → use a dedicated thread pool (`rayon`), not tokio's blocking pool which is sized for short bursts.
|
||||
|
||||
## Channels
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| 1-many producers → 1 consumer, async | `tokio::sync::mpsc::channel(cap)` |
|
||||
| Same as above, both sync + async | `flume::bounded(cap)` |
|
||||
| 1 → many fan-out, latest-value semantics | `tokio::sync::watch::channel(initial)` |
|
||||
| 1 → many fan-out, queued | `tokio::sync::broadcast::channel(cap)` |
|
||||
| One-shot reply | `tokio::sync::oneshot::channel()` |
|
||||
| Backpressure-driven stream of items | `tokio::sync::mpsc::Receiver` + `ReceiverStream` |
|
||||
|
||||
Mpsc pattern:
|
||||
|
||||
```rust
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Job>(256);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(job) = rx.recv().await {
|
||||
if let Err(error) = process(job).await {
|
||||
tracing::warn!(%error, "job failed");
|
||||
}
|
||||
}
|
||||
tracing::info!("queue closed, shutting down worker");
|
||||
});
|
||||
|
||||
tx.send(Job { ... }).await?; // blocks if full, applies backpressure
|
||||
```
|
||||
|
||||
Always bound channels. Unbounded channels are a memory leak waiting to happen.
|
||||
|
||||
## Streams
|
||||
|
||||
`futures::Stream` is the async analogue of `Iterator`. Use it for paginated fetches, long-poll responses, file lines.
|
||||
|
||||
```rust
|
||||
use futures::stream::{StreamExt, TryStreamExt};
|
||||
|
||||
let urls: Vec<String> = ...;
|
||||
let bodies: Vec<String> = futures::stream::iter(urls)
|
||||
.map(|url| async move { fetch(&url).await })
|
||||
.buffer_unordered(8) // up to 8 in flight
|
||||
.try_collect()
|
||||
.await?;
|
||||
```
|
||||
|
||||
`buffer_unordered(n)` is the throttle. Use it instead of spawning N tasks manually.
|
||||
|
||||
For producing a stream from a channel:
|
||||
|
||||
```rust
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(64);
|
||||
let stream = ReceiverStream::new(rx);
|
||||
serve_sse(stream).await
|
||||
```
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
```rust
|
||||
use tokio::signal;
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async { signal::ctrl_c().await.expect("ctrl_c handler") };
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let token = CancellationToken::new();
|
||||
let server = tokio::spawn(run_server(token.child_token()));
|
||||
shutdown_signal().await;
|
||||
token.cancel();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(10), server).await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Pattern: catch signal → cancel a token shared with the server → server's `select!` arms see the cancel and exit cleanly → wait with a timeout so a hung worker can't deadlock shutdown.
|
||||
|
||||
## Concurrency primitives
|
||||
|
||||
- `tokio::sync::Mutex` — async mutex. Use for state shared between async tasks. **Do not hold across `.await` without thinking** (you'll serialize the whole system).
|
||||
- `tokio::sync::RwLock` — async read-write lock. Same caveat.
|
||||
- `parking_lot::Mutex` — sync mutex, faster than `std::sync::Mutex`, no poisoning. Use when the lock is held briefly and you do not need to `.await` while holding it.
|
||||
- `tokio::sync::Semaphore` — bound concurrent operations. Perfect for "max 10 in-flight HTTP requests" or "max 3 DB writers".
|
||||
|
||||
```rust
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(10));
|
||||
for url in urls {
|
||||
let permit = sem.clone().acquire_owned().await?;
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // released on task end
|
||||
fetch(&url).await
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Holding a sync mutex across `.await`.** Compiles and runs, deadlocks at scale. Solution: refactor to release before await, or use `tokio::sync::Mutex`.
|
||||
2. **Forgetting `?` on `JoinHandle`.** A panicked task returns `Err(JoinError)`; if you `.await` and ignore, panics are silently swallowed.
|
||||
3. **`tokio::spawn` instead of `JoinSet`.** Detached tasks survive past their parent, causing leaks. Default to `JoinSet` for structured concurrency.
|
||||
4. **Unbounded channels.** Always set a capacity.
|
||||
5. **`block_on` inside an async context.** Causes deadlock under `current_thread` runtime, performance cliff under `multi_thread`.
|
||||
6. **CPU-heavy work in async fn.** Move to `spawn_blocking` or `rayon`.
|
||||
7. **No timeout on external I/O.** Every `await` that touches the network or filesystem needs `tokio::time::timeout` wrapping.
|
||||
|
||||
## Testing async code
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn fetches_and_parses() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("{\"id\":1}"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = my_client::fetch(&server.uri()).await.unwrap();
|
||||
assert_eq!(result.id, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn parallel_work() { ... }
|
||||
```
|
||||
|
||||
For time-sensitive tests, advance virtual time:
|
||||
|
||||
```rust
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn time_travel() {
|
||||
let start = tokio::time::Instant::now();
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
assert!(start.elapsed() >= Duration::from_secs(3600));
|
||||
// Real wallclock elapsed: ~0ms.
|
||||
}
|
||||
```
|
||||
|
||||
## When NOT to use async
|
||||
|
||||
- Single-threaded CPU-heavy code that does no I/O — plain `fn` + `rayon` is simpler and often faster.
|
||||
- Trivial scripts that do one HTTP call — `ureq` (sync) is simpler.
|
||||
- FFI heavy code where the FFI side is sync.
|
||||
|
||||
Async pays off when you have many concurrent I/O operations or need cancellation as a first-class primitive.
|
||||
@@ -0,0 +1,467 @@
|
||||
# axum + sqlx + tracing + tower — HTTP API Stack
|
||||
|
||||
The canonical production HTTP service in Rust 2026.
|
||||
|
||||
## Cargo.toml dependencies
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros", "tracing", "ws", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = [
|
||||
"trace", "compression-gzip", "compression-br",
|
||||
"timeout", "cors", "request-id", "sensitive-headers",
|
||||
"limit", "set-header",
|
||||
] }
|
||||
|
||||
# Errors / observability
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
color-eyre = "0.6"
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio-rustls", "postgres", "uuid", "macros",
|
||||
"migrate", "json",
|
||||
] }
|
||||
|
||||
# Serialization / validation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
validator = { version = "0.18", features = ["derive"] }
|
||||
|
||||
# Types
|
||||
uuid = { version = "1", features = ["v4", "v7", "serde"] }
|
||||
jiff = { version = "0.1", features = ["serde"] }
|
||||
|
||||
# Config
|
||||
config = { version = "0.14", features = ["toml", "yaml"] }
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
# OpenAPI (optional but recommended)
|
||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-axum = "0.1"
|
||||
utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs # binary entry
|
||||
lib.rs # re-exports + app builder
|
||||
config.rs # Settings type + loader
|
||||
state.rs # AppState (shared via Arc)
|
||||
routes/
|
||||
mod.rs # Router::new() composition
|
||||
health.rs
|
||||
users.rs
|
||||
middleware/
|
||||
mod.rs
|
||||
auth.rs
|
||||
request_id.rs
|
||||
models/
|
||||
mod.rs
|
||||
user.rs
|
||||
error.rs # AppError + IntoResponse impl
|
||||
db/
|
||||
mod.rs
|
||||
migrations/
|
||||
migrations/ # sqlx migrations
|
||||
```
|
||||
|
||||
## Error type
|
||||
|
||||
```rust
|
||||
// src/error.rs
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("validation: {0}")]
|
||||
Validation(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("internal")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
#[error("database")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match &self {
|
||||
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found", self.to_string()),
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized".into()),
|
||||
AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, "validation", m.clone()),
|
||||
AppError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!(error = ?e, "database error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "database", "internal".into())
|
||||
}
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!(error = ?e, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal".into())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({"error": {"code": code, "message": message}}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = std::result::Result<T, AppError>;
|
||||
```
|
||||
|
||||
Pattern: business errors return `AppResult<T>`; the `IntoResponse` impl translates them to HTTP. `sqlx::Error` and `anyhow::Error` auto-convert via `From`. Internal-bucket errors are logged but never leak their `Debug` representation to clients.
|
||||
|
||||
## AppState
|
||||
|
||||
```rust
|
||||
// src/state.rs
|
||||
use std::sync::Arc;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
pub config: Arc<crate::config::Settings>,
|
||||
pub http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: crate::config::Settings) -> anyhow::Result<Self> {
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(config.db.max_connections)
|
||||
.acquire_timeout(std::time::Duration::from_secs(3))
|
||||
.connect(config.db.url.expose_secret())
|
||||
.await?;
|
||||
sqlx::migrate!("./migrations").run(&db).await?;
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Self { db, config: Arc::new(config), http })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Route handler
|
||||
|
||||
```rust
|
||||
// src/routes/users.rs
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{error::{AppError, AppResult}, state::AppState};
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateUser {
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub created_at: jiff::Timestamp,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, body))]
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateUser>,
|
||||
) -> AppResult<(axum::http::StatusCode, Json<User>)> {
|
||||
body.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let user = sqlx::query_as!(
|
||||
User,
|
||||
r#"INSERT INTO users (id, email, name, created_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
RETURNING id, email, name, created_at as "created_at: jiff::Timestamp""#,
|
||||
id, body.email, body.name
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db) if db.code().as_deref() == Some("23505") =>
|
||||
AppError::Conflict("email already exists".into()),
|
||||
_ => AppError::Database(e),
|
||||
})?;
|
||||
|
||||
Ok((axum::http::StatusCode::CREATED, Json(user)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id, email, name, created_at as "created_at: jiff::Timestamp"
|
||||
FROM users WHERE id = $1"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
```
|
||||
|
||||
## Router assembly
|
||||
|
||||
```rust
|
||||
// src/routes/mod.rs
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
cors::CorsLayer,
|
||||
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
|
||||
sensitive_headers::SetSensitiveHeadersLayer,
|
||||
timeout::TimeoutLayer,
|
||||
trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use crate::state::AppState;
|
||||
|
||||
mod health;
|
||||
mod users;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
.route("/health", get(health::handler))
|
||||
.route("/users", post(users::create_user))
|
||||
.route("/users/:id", get(users::get_user))
|
||||
.with_state(state);
|
||||
|
||||
Router::new()
|
||||
.nest("/api/v1", api)
|
||||
.layer(
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(SetSensitiveHeadersLayer::new([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::COOKIE,
|
||||
]))
|
||||
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().include_headers(false))
|
||||
.on_response(DefaultOnResponse::new().latency_unit(tower_http::LatencyUnit::Millis)),
|
||||
)
|
||||
.layer(PropagateRequestIdLayer::x_request_id())
|
||||
.layer(TimeoutLayer::new(Duration::from_secs(30)))
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(CorsLayer::permissive()), // tighten in production
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Order matters: outermost layer wraps the request first. Trace before timeout so timeouts get logged. Compression after trace so trace sees the original body size.
|
||||
|
||||
## Main + graceful shutdown
|
||||
|
||||
```rust
|
||||
// src/main.rs
|
||||
use my_app::{config::Settings, routes, state::AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
init_tracing();
|
||||
|
||||
let config = Settings::load()?;
|
||||
let state = AppState::new(config.clone()).await?;
|
||||
let app = routes::router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind).await?;
|
||||
tracing::info!(addr = %config.bind, "listening");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,tower_http=info"));
|
||||
fmt().with_env_filter(filter).with_target(false).json().init();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl_c handler"); };
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("signal handler").recv().await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
||||
tracing::info!("shutting down");
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware: bearer auth example
|
||||
|
||||
```rust
|
||||
// src/middleware/auth.rs
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::AUTHORIZATION,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUser { pub id: uuid::Uuid }
|
||||
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let token = request.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = verify_jwt(token, &state.config.jwt_secret)?;
|
||||
request.extensions_mut().insert(AuthUser { id: claims.sub });
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
```
|
||||
|
||||
Apply with `.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))` on the subroutes that need it.
|
||||
|
||||
## Testing handlers
|
||||
|
||||
```rust
|
||||
// tests/users.rs
|
||||
#[tokio::test]
|
||||
async fn creates_user() {
|
||||
let pool = test_db().await; // helper that spins up a transactional DB
|
||||
let state = AppState::new_test(pool).await.unwrap();
|
||||
let app = my_app::routes::router(state);
|
||||
|
||||
let request = axum::http::Request::builder()
|
||||
.uri("/api/v1/users")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({"email": "a@b.com", "name": "A"})).unwrap()
|
||||
)).unwrap();
|
||||
|
||||
let response = tower::ServiceExt::oneshot(app, request).await.unwrap();
|
||||
assert_eq!(response.status(), 201);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap();
|
||||
let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(user["email"], "a@b.com");
|
||||
}
|
||||
```
|
||||
|
||||
`tower::ServiceExt::oneshot` calls the router directly without binding a socket. Tests run in parallel without port collisions.
|
||||
|
||||
## Config
|
||||
|
||||
```rust
|
||||
// src/config.rs
|
||||
use secrecy::{Secret, ExposeSecret};
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct Settings {
|
||||
pub bind: String,
|
||||
pub db: Database,
|
||||
pub jwt_secret: Secret<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct Database {
|
||||
pub url: Secret<String>,
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let cfg = config::Config::builder()
|
||||
.add_source(config::File::with_name("config/default").required(false))
|
||||
.add_source(config::File::with_name(&format!(
|
||||
"config/{}", std::env::var("APP_ENV").unwrap_or_else(|_| "dev".into())
|
||||
)).required(false))
|
||||
.add_source(config::Environment::with_prefix("APP").separator("__"))
|
||||
.build()?;
|
||||
Ok(cfg.try_deserialize()?)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Secret<T>` from the `secrecy` crate hides the value in `Debug`/`Display` to prevent accidental log leakage. Access via `.expose_secret()` only where needed.
|
||||
|
||||
## OpenAPI (optional)
|
||||
|
||||
Add `utoipa` derive macros on your DTOs and handlers, mount Swagger UI at `/swagger-ui`:
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_axum::router::OpenApiRouter;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(routes::users::create_user, routes::users::get_user),
|
||||
components(schemas(routes::users::User, routes::users::CreateUser))
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||
.routes(utoipa_axum::routes!(routes::users::create_user, routes::users::get_user))
|
||||
.split_for_parts();
|
||||
|
||||
let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api));
|
||||
```
|
||||
|
||||
## Production checklist
|
||||
|
||||
- Bind to `0.0.0.0` in containers, `127.0.0.1` for local-only services.
|
||||
- Set `RUST_LOG=info,sqlx=warn` (or use `EnvFilter` defaults as shown).
|
||||
- Send logs to stdout in JSON. Ingest via Vector / Fluent Bit / Loki.
|
||||
- Run migrations on startup (`sqlx::migrate!` block). Fail fast on schema mismatch.
|
||||
- Health endpoint **must hit the DB** (so load balancers know if the pool is dead).
|
||||
- Add `tower::limit::RateLimitLayer` or token-bucket middleware for public endpoints.
|
||||
- Set `tower_http::limit::RequestBodyLimitLayer` to bound request size.
|
||||
- Compress with brotli + gzip via `CompressionLayer`.
|
||||
- Tighten CORS, do not ship `CorsLayer::permissive()` to production.
|
||||
- Strip sensitive headers from traces via `SetSensitiveHeadersLayer`.
|
||||
- Set up SIGTERM-driven `with_graceful_shutdown` so deploys roll without dropping requests.
|
||||
- Containerize with `cargo chef` for incremental Docker builds.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Forgetting `error_for_status()?` on outbound `reqwest`** — 4xx silently succeeds.
|
||||
2. **Returning `Result<T, sqlx::Error>` from handlers** — leak DB details to clients. Always go through `AppError`.
|
||||
3. **`Json<T>` extractor before validation** — invalid JSON returns axum's default 422 with no body shape. Wrap in a `ValidatedJson<T>` extractor that runs `validator` and returns `AppError`.
|
||||
4. **Holding DB connections across `.await` on slow external calls** — exhausts the pool. Acquire late, release early.
|
||||
5. **Skipping `tracing::instrument`** on handlers — losing per-request span correlation.
|
||||
6. **No `RequestBodyLimitLayer`** — DoS surface. Default axum has no limit.
|
||||
@@ -0,0 +1,317 @@
|
||||
# Cargo Strict Configuration
|
||||
|
||||
The exact knobs every new Rust project gets. Drop these in unmodified.
|
||||
|
||||
## `rust-toolchain.toml`
|
||||
|
||||
```toml
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy", "rust-src"]
|
||||
profile = "default"
|
||||
```
|
||||
|
||||
Pin nightly separately when miri runs:
|
||||
|
||||
```bash
|
||||
rustup install nightly
|
||||
rustup component add miri rust-src --toolchain nightly
|
||||
```
|
||||
|
||||
## `Cargo.toml` — `[lints]` section
|
||||
|
||||
```toml
|
||||
[lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
missing_docs = "warn"
|
||||
missing_debug_implementations = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
non_ascii_idents = "deny"
|
||||
trivial_numeric_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
single_use_lifetimes = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
# Groups
|
||||
all = { level = "deny", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
cargo = { level = "warn", priority = -1 }
|
||||
|
||||
# Hard denies - turn warnings into errors for sharp tools
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
multiple_unsafe_ops_per_block = "deny"
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
indexing_slicing = "deny"
|
||||
mem_forget = "deny"
|
||||
arithmetic_side_effects = "warn"
|
||||
cast_possible_truncation = "warn"
|
||||
cast_possible_wrap = "warn"
|
||||
cast_precision_loss = "warn"
|
||||
cast_sign_loss = "warn"
|
||||
as_underscore = "deny"
|
||||
as_ptr_cast_mut = "deny"
|
||||
ptr_as_ptr = "warn"
|
||||
borrow_as_ptr = "warn"
|
||||
fn_to_numeric_cast_any = "deny"
|
||||
clone_on_ref_ptr = "warn"
|
||||
mutex_atomic = "warn"
|
||||
rc_buffer = "warn"
|
||||
rc_mutex = "warn"
|
||||
exit = "warn"
|
||||
allow_attributes_without_reason = "warn"
|
||||
dbg_macro = "warn"
|
||||
print_stderr = "warn"
|
||||
print_stdout = "warn"
|
||||
use_debug = "warn"
|
||||
|
||||
# Stylistic relaxations (project-wide opinions only)
|
||||
module_name_repetitions = "allow"
|
||||
must_use_candidate = "allow"
|
||||
missing_errors_doc = "allow" # we use anyhow::Result with .context() everywhere; doc rule is noisy
|
||||
|
||||
# Restriction lints - opt-in soundness rails
|
||||
unreachable = "deny"
|
||||
mod_module_files = "warn" # prefer foo.rs over foo/mod.rs
|
||||
empty_drop = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
empty_enum = "warn"
|
||||
exhaustive_enums = "warn" # public enums should consider #[non_exhaustive]
|
||||
exhaustive_structs = "warn"
|
||||
```
|
||||
|
||||
The `priority = -1` trick: group-level levels are weak; specific lints below them win. This lets us deny `unwrap_used` while still allowing `pedantic` group warnings instead of denies.
|
||||
|
||||
## `Cargo.toml` — release profile
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
panic = "abort" # smaller, faster - if you need unwinding (FFI catch), set "unwind"
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
incremental = true
|
||||
codegen-units = 256
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
# A profile for miri - opt-level 1 keeps simulation bearable while still
|
||||
# exercising real codegen patterns. miri ignores most profile keys but reads
|
||||
# overflow-checks.
|
||||
[profile.miri]
|
||||
inherits = "test"
|
||||
opt-level = 1
|
||||
overflow-checks = true
|
||||
```
|
||||
|
||||
## `Cargo.toml` — workspace level
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.83" # bump only when a needed feature lands
|
||||
license = "Apache-2.0 OR MIT"
|
||||
|
||||
[workspace.lints]
|
||||
# Then in each member crate:
|
||||
# [lints]
|
||||
# workspace = true
|
||||
```
|
||||
|
||||
## `rustfmt.toml`
|
||||
|
||||
```toml
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
newline_style = "Unix"
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
unstable_features = false
|
||||
```
|
||||
|
||||
Most options come from stable rustfmt. `imports_granularity` and `group_imports` are nightly-only but ignored cleanly on stable; CI runs `cargo +nightly fmt --check` for the import grouping.
|
||||
|
||||
## `clippy.toml`
|
||||
|
||||
```toml
|
||||
# Reduce cognitive load thresholds.
|
||||
cognitive-complexity-threshold = 25
|
||||
type-complexity-threshold = 250
|
||||
too-many-arguments-threshold = 6
|
||||
too-many-lines-threshold = 100
|
||||
|
||||
# msrv - keeps clippy from suggesting features past our MSRV
|
||||
msrv = "1.83"
|
||||
|
||||
# Avoid `panic` lint complaining about derived Debug impls calling unreachable_unchecked etc.
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-dbg-in-tests = true
|
||||
allow-print-in-tests = true
|
||||
|
||||
# Force named arguments above N params
|
||||
single-char-binding-names-threshold = 4
|
||||
```
|
||||
|
||||
## `deny.toml` (cargo-deny)
|
||||
|
||||
```toml
|
||||
[advisories]
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
yanked = "deny"
|
||||
ignore = []
|
||||
|
||||
[licenses]
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"MPL-2.0",
|
||||
"CC0-1.0",
|
||||
]
|
||||
confidence-threshold = 0.93
|
||||
exceptions = []
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "deny"
|
||||
highlight = "all"
|
||||
deny = [
|
||||
# Pin out unmaintained alternatives
|
||||
{ name = "async-std", reason = "use tokio" },
|
||||
{ name = "actix-web", reason = "use axum" },
|
||||
{ name = "chrono", reason = "use jiff" },
|
||||
]
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "warn"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
```
|
||||
|
||||
## CI Workflow (`.github/workflows/ci.yml`)
|
||||
|
||||
```yaml
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo +nightly fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo clippy --all-targets --all-features --workspace -- -D warnings
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- run: cargo nextest run --all-targets --all-features --workspace
|
||||
|
||||
miri:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: miri, rust-src
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- env:
|
||||
MIRIFLAGS: "-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check"
|
||||
run: cargo +nightly miri nextest run --all-features --workspace
|
||||
|
||||
machete:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: bnjbvr/cargo-machete@main
|
||||
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check all
|
||||
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: rustsec/audit-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Project bootstrap
|
||||
|
||||
```bash
|
||||
cargo new --bin my-app --edition 2024
|
||||
cd my-app
|
||||
cargo install cargo-nextest cargo-machete cargo-deny cargo-edit cargo-watch
|
||||
rustup install nightly
|
||||
rustup component add miri --toolchain nightly
|
||||
# drop the configs above
|
||||
git add . && git commit -m "chore: bootstrap strict toolchain"
|
||||
```
|
||||
|
||||
After every change:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check && \
|
||||
cargo clippy --all-targets --all-features -- -D warnings && \
|
||||
cargo nextest run && \
|
||||
cargo +nightly miri nextest run # only if unsafe is involved
|
||||
```
|
||||
@@ -0,0 +1,409 @@
|
||||
# CLI Stack — clap + color-eyre + tracing + indicatif + dialoguer
|
||||
|
||||
The default for any new CLI tool. Strict typing on arguments, beautiful errors, progress feedback, interactive prompts when needed.
|
||||
|
||||
## Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "mytool"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive", "env", "wrap_help", "color", "unicode"] }
|
||||
clap_complete = "4"
|
||||
color-eyre = "0.6"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
anyhow = "1"
|
||||
indicatif = { version = "0.17", features = ["tokio"] }
|
||||
dialoguer = { version = "0.11", features = ["fuzzy-select"] }
|
||||
console = "0.15"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "process", "signal"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
panic = "abort"
|
||||
```
|
||||
|
||||
## Command structure
|
||||
|
||||
```rust
|
||||
// src/cli.rs
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "mytool",
|
||||
author,
|
||||
version,
|
||||
about = "A short description",
|
||||
long_about = "A longer description that appears in --help",
|
||||
arg_required_else_help = true,
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Configuration file path
|
||||
#[arg(short, long, env = "MYTOOL_CONFIG", default_value = "config.toml", global = true)]
|
||||
pub config: PathBuf,
|
||||
|
||||
/// Increase verbosity (-v info, -vv debug, -vvv trace)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
pub verbose: u8,
|
||||
|
||||
/// Suppress all non-error output
|
||||
#[arg(short, long, global = true, conflicts_with = "verbose")]
|
||||
pub quiet: bool,
|
||||
|
||||
/// Force colored output even when stdout is not a terminal
|
||||
#[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
|
||||
pub color: ColorChoice,
|
||||
|
||||
/// Output format
|
||||
#[arg(short, long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
|
||||
pub format: OutputFormat,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum ColorChoice { Auto, Always, Never }
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum OutputFormat { Pretty, Json, Plain }
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Build the thing
|
||||
Build(BuildArgs),
|
||||
/// Watch and rebuild
|
||||
Watch(WatchArgs),
|
||||
/// Generate shell completions
|
||||
Completions { #[arg(value_enum)] shell: clap_complete::Shell },
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct BuildArgs {
|
||||
/// Target directory
|
||||
#[arg(short, long, default_value = "target")]
|
||||
pub target: PathBuf,
|
||||
/// Build mode
|
||||
#[arg(short, long, value_enum, default_value_t = Mode::Release)]
|
||||
pub mode: Mode,
|
||||
/// Specific files to build (default: all)
|
||||
pub files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct WatchArgs {
|
||||
/// Glob pattern to watch
|
||||
#[arg(short, long, default_value = "**/*.rs")]
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum Mode { Debug, Release }
|
||||
```
|
||||
|
||||
Key clap derive patterns:
|
||||
|
||||
- `env = "VAR"` — falls back to env var if flag not given.
|
||||
- `global = true` — flag inherits to subcommands.
|
||||
- `arg_required_else_help = true` — running with no args prints help instead of erroring.
|
||||
- `value_enum` on an enum — case-insensitive parsing + auto-completion.
|
||||
- `action = clap::ArgAction::Count` — `-v` is 1, `-vv` is 2, etc.
|
||||
- `conflicts_with` — incompatible flags.
|
||||
|
||||
## Main + tracing init
|
||||
|
||||
```rust
|
||||
// src/main.rs
|
||||
use clap::Parser;
|
||||
use mytool::cli::{Cli, Command, ColorChoice};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
let cli = Cli::parse();
|
||||
init_tracing(&cli);
|
||||
|
||||
if matches!(cli.color, ColorChoice::Always) {
|
||||
console::set_colors_enabled(true);
|
||||
} else if matches!(cli.color, ColorChoice::Never) {
|
||||
console::set_colors_enabled(false);
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Command::Build(args) => mytool::commands::build::run(&cli, args),
|
||||
Command::Watch(args) => mytool::commands::watch::run(&cli, args),
|
||||
Command::Completions { shell } => {
|
||||
let mut cmd = <Cli as clap::CommandFactory>::command();
|
||||
clap_complete::generate(shell, &mut cmd, "mytool", &mut std::io::stdout());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing(cli: &Cli) {
|
||||
let level = if cli.quiet {
|
||||
Level::ERROR
|
||||
} else {
|
||||
match cli.verbose {
|
||||
0 => Level::WARN,
|
||||
1 => Level::INFO,
|
||||
2 => Level::DEBUG,
|
||||
_ => Level::TRACE,
|
||||
}
|
||||
};
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(format!("mytool={level}")));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.compact()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
```
|
||||
|
||||
Tracing on a CLI:
|
||||
- **Write to stderr.** stdout is for the tool's actual output (which the user might pipe). Logs and progress bars go to stderr.
|
||||
- **Verbosity from `-v`, not from `RUST_LOG`.** Users expect `-v` on a CLI; `RUST_LOG` is a developer escape hatch (kept, but secondary).
|
||||
|
||||
## Progress bars — `indicatif`
|
||||
|
||||
```rust
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::time::Duration;
|
||||
|
||||
let mp = MultiProgress::new();
|
||||
let pb = mp.add(ProgressBar::new(files.len() as u64));
|
||||
pb.set_style(
|
||||
ProgressStyle::with_template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} ({eta}) {msg}"
|
||||
)?
|
||||
.progress_chars("=>-")
|
||||
);
|
||||
|
||||
for file in files {
|
||||
pb.set_message(file.display().to_string());
|
||||
process(&file)?;
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_with_message("done");
|
||||
```
|
||||
|
||||
For unbounded operations:
|
||||
|
||||
```rust
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
spinner.enable_steady_tick(Duration::from_millis(80));
|
||||
spinner.set_message("connecting…");
|
||||
let result = connect().await?;
|
||||
spinner.finish_and_clear();
|
||||
```
|
||||
|
||||
With multiple parallel tasks:
|
||||
|
||||
```rust
|
||||
let mp = MultiProgress::new();
|
||||
let bars: Vec<_> = (0..workers).map(|i| {
|
||||
let pb = mp.add(ProgressBar::new(unit));
|
||||
pb.set_style(ProgressStyle::with_template("worker {prefix}: {pos}/{len}")?);
|
||||
pb.set_prefix(i.to_string());
|
||||
pb
|
||||
}).collect();
|
||||
```
|
||||
|
||||
`MultiProgress` keeps bars stacked and redraws cleanly even with concurrent updates from multiple tasks.
|
||||
|
||||
When stdout is not a terminal, indicatif silently disables animation. Force on/off with `pb.set_draw_target(ProgressDrawTarget::stdout())` / `hidden()`.
|
||||
|
||||
## Interactive prompts — `dialoguer`
|
||||
|
||||
```rust
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select, FuzzySelect, MultiSelect};
|
||||
|
||||
let name: String = Input::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Project name")
|
||||
.validate_with(|input: &String| -> Result<(), &str> {
|
||||
if input.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("alphanumeric, dash, underscore only")
|
||||
}
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
let secret = Password::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("API key")
|
||||
.with_confirmation("Repeat", "passwords don't match")
|
||||
.interact()?;
|
||||
|
||||
let go: bool = Confirm::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(format!("Delete {}? This cannot be undone.", path.display()))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !go { return Ok(()); }
|
||||
|
||||
let items = ["yes", "no", "maybe"];
|
||||
let idx = Select::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Pick one")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
let picks = MultiSelect::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Toggle features")
|
||||
.items(&["alpha", "beta", "gamma"])
|
||||
.defaults(&[true, false, false])
|
||||
.interact()?;
|
||||
```
|
||||
|
||||
Detect non-TTY before prompting:
|
||||
|
||||
```rust
|
||||
if !console::user_attended() {
|
||||
return Err(anyhow::anyhow!("input required but stdin is not a terminal"));
|
||||
}
|
||||
```
|
||||
|
||||
For automated tests, expose a `--non-interactive` flag and gate all prompts behind it.
|
||||
|
||||
## Structured output
|
||||
|
||||
```rust
|
||||
match cli.format {
|
||||
OutputFormat::Json => {
|
||||
serde_json::to_writer(std::io::stdout().lock(), &result)?;
|
||||
println!();
|
||||
}
|
||||
OutputFormat::Plain => {
|
||||
for row in &result.rows {
|
||||
println!("{}\t{}\t{}", row.a, row.b, row.c);
|
||||
}
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
use console::{style, Term};
|
||||
let term = Term::stdout();
|
||||
for row in &result.rows {
|
||||
term.write_line(&format!(
|
||||
"{} {} {}",
|
||||
style(&row.a).green(),
|
||||
style(&row.b).yellow(),
|
||||
style(&row.c).dim(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Always offer `--format json` for piping into `jq`, scripts, and other tools.
|
||||
|
||||
## Shell completions
|
||||
|
||||
Already shown in the `Completions` subcommand above. Distribute completions by adding to the install script:
|
||||
|
||||
```bash
|
||||
mytool completions bash > /etc/bash_completion.d/mytool
|
||||
mytool completions fish > ~/.config/fish/completions/mytool.fish
|
||||
mytool completions zsh > "${fpath[1]}/_mytool"
|
||||
```
|
||||
|
||||
## Signal handling
|
||||
|
||||
```rust
|
||||
// In an async CLI command
|
||||
use tokio::signal::ctrl_c;
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c() => {
|
||||
tracing::warn!("interrupted, cleaning up");
|
||||
cleanup().await?;
|
||||
std::process::exit(130); // standard exit code for SIGINT
|
||||
}
|
||||
result = long_running_task() => {
|
||||
result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For sync CLIs, install a one-shot handler with `ctrlc` crate:
|
||||
|
||||
```rust
|
||||
let interrupted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let i = interrupted.clone();
|
||||
ctrlc::set_handler(move || i.store(true, std::sync::atomic::Ordering::SeqCst))?;
|
||||
|
||||
while !interrupted.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
do_step()?;
|
||||
}
|
||||
```
|
||||
|
||||
## Error reporting with color-eyre
|
||||
|
||||
```rust
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::config::HookBuilder::default()
|
||||
.display_env_section(false) // hide SPANTRACE/BACKTRACE env hints by default
|
||||
.display_location_section(false) // hide file:line section
|
||||
.panic_section("If this is a bug, please report at https://github.com/me/mytool/issues")
|
||||
.install()?;
|
||||
real_main()
|
||||
}
|
||||
```
|
||||
|
||||
Errors with `.wrap_err("...")` from `eyre::WrapErr` (compatible with anyhow's `.context`) show as a numbered chain. `RUST_BACKTRACE=1` shows the full trace; `RUST_SPANTRACE=1` shows tracing spans where the error fired.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Add `cargo dist init` for prebuilt binary release pipeline (cross-platform tarballs + installers).
|
||||
- Publish to Homebrew tap, AUR, scoop, Chocolatey via dist.
|
||||
- Sign Linux binaries with `cosign` if your audience is enterprise.
|
||||
- Build single static binary on Linux with `--target x86_64-unknown-linux-musl` (or `aarch64-unknown-linux-musl`).
|
||||
- For wasm-runnable CLIs (`wasi-cli`), add `--target wasm32-wasip1`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Mixing stdout and stderr.** Tool output goes to stdout; logs and progress go to stderr.
|
||||
2. **No `--non-interactive` flag.** Interactive prompts block automation.
|
||||
3. **Printing colored output unconditionally.** Honor `NO_COLOR` env var, detect TTY with `console::user_attended()`.
|
||||
4. **`println!` for errors.** Use `tracing::error!` so logs go to stderr automatically and respect verbosity.
|
||||
5. **`unwrap()` on `Cli::parse()`.** clap returns clean errors with `--help` text; `parse()` exits on its own.
|
||||
6. **Long subcommand handlers in `main.rs`.** Split into `src/commands/<name>.rs` per command.
|
||||
7. **Missing exit code semantics.** Use `std::process::exit(1)` (general error), `2` (usage), `130` (SIGINT) appropriately. Or return `Result` and let main map.
|
||||
|
||||
## Testing CLIs
|
||||
|
||||
```rust
|
||||
// tests/cli.rs
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn shows_help() {
|
||||
Command::cargo_bin("mytool").unwrap()
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_subcommand() {
|
||||
Command::cargo_bin("mytool").unwrap()
|
||||
.arg("nope")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("unrecognized subcommand"));
|
||||
}
|
||||
```
|
||||
|
||||
`assert_cmd` builds the binary once per test run and gives a fluent assertion API.
|
||||
@@ -0,0 +1,375 @@
|
||||
# Concurrency Primitives
|
||||
|
||||
Locks, atomics, channels, and the loom model checker. The decision tree that keeps the agent out of soundness trouble.
|
||||
|
||||
## The pyramid
|
||||
|
||||
```
|
||||
Highest level tokio::sync::mpsc / broadcast / watch
|
||||
(message passing — default for new code)
|
||||
|
||||
Arc<Mutex<T>> / Arc<RwLock<T>>
|
||||
(shared mutable state — common, easy to get right)
|
||||
|
||||
parking_lot::{Mutex, RwLock, Condvar}
|
||||
(faster sync locks, no poisoning)
|
||||
|
||||
Atomics (AtomicUsize, AtomicBool, AtomicPtr)
|
||||
(single-word lock-free state)
|
||||
|
||||
Lowest level UnsafeCell + unsafe + loom + miri
|
||||
(custom lock-free / wait-free primitives)
|
||||
```
|
||||
|
||||
**Always start at the top.** Drop a level only when you have measured a real bottleneck.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Need to share state between tasks?
|
||||
├── State is configuration (read-only after start)
|
||||
│ └── Arc<Config> (no lock needed)
|
||||
├── State is a queue of work
|
||||
│ └── tokio::sync::mpsc::channel(cap)
|
||||
├── State is "latest value" published to many readers
|
||||
│ └── tokio::sync::watch::channel(initial)
|
||||
├── State is broadcast (every consumer sees every value)
|
||||
│ └── tokio::sync::broadcast::channel(cap)
|
||||
├── State is request-response within one task tree
|
||||
│ └── tokio::sync::oneshot::channel()
|
||||
├── State is a counter
|
||||
│ └── AtomicU64 (or AtomicUsize)
|
||||
├── State is a flag / set-once
|
||||
│ └── AtomicBool / OnceLock<T> / OnceCell<T>
|
||||
├── State needs mutation across many tasks/threads, cheap critical sections
|
||||
│ ├── async context → tokio::sync::Mutex<T>
|
||||
│ └── sync context (no .await held) → parking_lot::Mutex<T>
|
||||
├── State needs mutation, many readers, few writers
|
||||
│ ├── async context → tokio::sync::RwLock<T>
|
||||
│ └── sync context → parking_lot::RwLock<T>
|
||||
└── State is a custom lock-free primitive (channels, hazard pointers)
|
||||
└── UnsafeCell + atomics + loom-tested + miri-tested + a co-author
|
||||
```
|
||||
|
||||
## Atomics — when and how
|
||||
|
||||
Use atomics for:
|
||||
- Counters incremented from many threads (`AtomicU64`).
|
||||
- Single-shot flags (`AtomicBool`).
|
||||
- Pointer publication (`AtomicPtr<T>`).
|
||||
|
||||
### Memory orderings
|
||||
|
||||
```rust
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let c = AtomicUsize::new(0);
|
||||
|
||||
// Just need a count, no synchronization with other data
|
||||
c.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Reading a counter that was incremented from elsewhere
|
||||
let n = c.load(Ordering::Relaxed);
|
||||
```
|
||||
|
||||
| Ordering | When |
|
||||
|---|---|
|
||||
| `Relaxed` | Standalone counters, no other memory needs to be synchronized. |
|
||||
| `Acquire` (loads) / `Release` (stores) | Publish/consume pattern: you write some data then release a flag, readers acquire the flag then read the data. |
|
||||
| `AcqRel` | RMW that both reads-and-publishes (e.g., `fetch_add` on a sequence number). |
|
||||
| `SeqCst` | Total ordering across all `SeqCst` ops. Strongest, slowest. Use when in doubt and switch to a weaker ordering after testing under loom. |
|
||||
|
||||
**Default to `SeqCst` if unsure.** Performance difference is usually negligible. Going weaker requires loom.
|
||||
|
||||
### Publish-then-load pattern
|
||||
|
||||
```rust
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
static mut DATA: Option<Config> = None;
|
||||
|
||||
// Producer thread:
|
||||
unsafe { DATA = Some(load_config()); }
|
||||
READY.store(true, Ordering::Release);
|
||||
|
||||
// Consumer thread:
|
||||
if READY.load(Ordering::Acquire) {
|
||||
// SAFETY: producer's Release pairs with our Acquire; if we see READY=true,
|
||||
// we are guaranteed to also see the DATA write that happened-before it.
|
||||
let cfg = unsafe { DATA.as_ref().unwrap() };
|
||||
}
|
||||
```
|
||||
|
||||
This is the canonical Release/Acquire pattern. **Use `OnceLock<Config>` instead** in new code — it encapsulates exactly this with safe API.
|
||||
|
||||
## Std vs parking_lot vs tokio for locks
|
||||
|
||||
| | std::sync::Mutex | parking_lot::Mutex | tokio::sync::Mutex |
|
||||
|---|---|---|---|
|
||||
| Speed | Slowest (OS futex direct) | Fastest (smarter parking) | Slow (await-aware) |
|
||||
| Poisoning | Yes (`PoisonError`) | No | No |
|
||||
| Hold across `.await` | Dangerous (deadlock under current-thread runtime) | Dangerous | Safe |
|
||||
| Drop guard releases | Yes | Yes | Yes |
|
||||
| RAII | Yes (`MutexGuard`) | Yes | Yes |
|
||||
| Const constructor | Yes (since 1.63) | Yes | No |
|
||||
| Async | No | No | Yes |
|
||||
|
||||
**Rule of thumb:**
|
||||
|
||||
- Hot, short critical section, no await inside → `parking_lot::Mutex`.
|
||||
- Shared state held across `.await` → `tokio::sync::Mutex`.
|
||||
- Static init / app config → `OnceLock` or `LazyLock`.
|
||||
- Avoid `std::sync::Mutex` for new code; the poisoning behavior is more annoying than useful and `parking_lot` is strictly faster.
|
||||
|
||||
### Common deadlock — async + sync mutex
|
||||
|
||||
```rust
|
||||
let m = std::sync::Mutex::new(0u64);
|
||||
let guard = m.lock().unwrap();
|
||||
something_async().await; // ❌ guard is held across await
|
||||
*guard += 1;
|
||||
```
|
||||
|
||||
Under `current_thread` runtime this deadlocks (the future suspends while holding the lock; another future on the same thread tries to acquire, blocks the executor). Under `multi_thread` it works but serializes the system.
|
||||
|
||||
Fix:
|
||||
|
||||
```rust
|
||||
{
|
||||
let mut guard = m.lock().unwrap();
|
||||
*guard += 1;
|
||||
} // guard released
|
||||
something_async().await;
|
||||
```
|
||||
|
||||
Or switch to `tokio::sync::Mutex` whose guard is `Send` across awaits.
|
||||
|
||||
## Channels
|
||||
|
||||
### Mpsc — the workhorse
|
||||
|
||||
```rust
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Job>(256);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(job) = rx.recv().await {
|
||||
process(job).await;
|
||||
}
|
||||
});
|
||||
|
||||
tx.send(Job::new()).await?; // backpressure: awaits if full
|
||||
```
|
||||
|
||||
Capacity is the backpressure budget. **Never `unbounded_channel()`** unless you have a hard upper bound elsewhere; otherwise it is a slow-leak memory bomb.
|
||||
|
||||
### Watch — latest-value pubsub
|
||||
|
||||
```rust
|
||||
let (tx, mut rx) = tokio::sync::watch::channel(Config::default());
|
||||
|
||||
// Producer:
|
||||
tx.send(new_config)?;
|
||||
|
||||
// Consumer:
|
||||
loop {
|
||||
rx.changed().await?;
|
||||
let cfg = rx.borrow();
|
||||
apply(&cfg);
|
||||
}
|
||||
```
|
||||
|
||||
Receivers see only the latest value (older updates are dropped). Perfect for config reload, leadership changes, "current time" propagation.
|
||||
|
||||
### Broadcast — fanout queue
|
||||
|
||||
```rust
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<Event>(1024);
|
||||
let mut rx1 = tx.subscribe();
|
||||
let mut rx2 = tx.subscribe();
|
||||
|
||||
while let Ok(event) = rx1.recv().await {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Each subscriber has its own buffer. If a subscriber falls behind by more than the buffer size, it gets `RecvError::Lagged(n)` and skips messages. Decide explicitly: log + continue, or drop the subscriber and reconnect.
|
||||
|
||||
### Oneshot — single value
|
||||
|
||||
```rust
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Response>();
|
||||
worker.send(Request { reply: tx }).await?;
|
||||
let response = rx.await?;
|
||||
```
|
||||
|
||||
The standard request/response pattern over an actor.
|
||||
|
||||
## Semaphores
|
||||
|
||||
Bound concurrent operations:
|
||||
|
||||
```rust
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(10));
|
||||
|
||||
for task in tasks {
|
||||
let permit = sem.clone().acquire_owned().await?;
|
||||
tokio::spawn(async move {
|
||||
let _hold = permit; // released when task exits
|
||||
process(task).await
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- "Max 10 outbound HTTP requests in flight."
|
||||
- "Max 3 DB connections doing writes."
|
||||
- "Max N tokio tasks running heavy CPU."
|
||||
|
||||
A semaphore with `permits=1` is a mutex. Use the actual `Mutex` for that — clearer intent.
|
||||
|
||||
## Arc and Rc
|
||||
|
||||
`Arc<T>` for cross-thread shared ownership, `Rc<T>` for single-thread (never spans threads).
|
||||
|
||||
```rust
|
||||
let shared = Arc::new(BigData::new());
|
||||
for _ in 0..workers {
|
||||
let s = shared.clone();
|
||||
tokio::spawn(async move { use_data(&s).await });
|
||||
}
|
||||
```
|
||||
|
||||
`Arc::clone(&s)` is just a reference-count increment; the data is not copied.
|
||||
|
||||
**Do not clone in hot loops** if you can pass a reference. `&Arc<T>` is fine to pass; only call `Arc::clone` when you need to move ownership across a thread/task boundary.
|
||||
|
||||
`Weak<T>` for back-references in graphs / parent pointers to avoid cycles.
|
||||
|
||||
## Once-init primitives
|
||||
|
||||
```rust
|
||||
use std::sync::{OnceLock, LazyLock};
|
||||
|
||||
// Lazy initialization, computed on first read
|
||||
static CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load_from_env().unwrap());
|
||||
|
||||
fn get_config() -> &'static Config {
|
||||
&CONFIG
|
||||
}
|
||||
|
||||
// One-shot publication, set explicitly
|
||||
static DB: OnceLock<sqlx::PgPool> = OnceLock::new();
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let pool = sqlx::PgPool::connect(&env_url()).await.unwrap();
|
||||
DB.set(pool).expect("only set once");
|
||||
// Now everywhere: DB.get().unwrap()
|
||||
}
|
||||
```
|
||||
|
||||
`OnceLock` is `std::sync` and stable. `LazyLock` is in `std::sync` since 1.80. Avoid the older `once_cell` crate for new code.
|
||||
|
||||
## Loom — model-checking lock-free code
|
||||
|
||||
When `unsafe` participates in a concurrent algorithm, miri's single-thread model is insufficient. Loom exhaustively explores thread interleavings.
|
||||
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[target.'cfg(loom)'.dev-dependencies]
|
||||
loom = "0.7"
|
||||
```
|
||||
|
||||
In code, switch between real and loom primitives:
|
||||
|
||||
```rust
|
||||
#[cfg(loom)]
|
||||
use loom::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[cfg(not(loom))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(loom)]
|
||||
use loom::sync::Arc;
|
||||
#[cfg(not(loom))]
|
||||
use std::sync::Arc;
|
||||
```
|
||||
|
||||
Write a test:
|
||||
|
||||
```rust
|
||||
#[cfg(loom)]
|
||||
mod loom_tests {
|
||||
use super::*;
|
||||
use loom::thread;
|
||||
|
||||
#[test]
|
||||
fn concurrent_push_pop_preserves_order() {
|
||||
loom::model(|| {
|
||||
let queue = Arc::new(MyQueue::new());
|
||||
let q1 = queue.clone();
|
||||
let q2 = queue.clone();
|
||||
let h1 = thread::spawn(move || q1.push(1));
|
||||
let h2 = thread::spawn(move || q2.pop());
|
||||
h1.join().unwrap();
|
||||
h2.join().unwrap();
|
||||
// Assert the invariant: queue is in a coherent state.
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="--cfg loom" cargo test --release -- --test-threads 1
|
||||
```
|
||||
|
||||
Loom explores every legal scheduling of the threads, including those a real scheduler would rarely produce. If your code has a race, loom will find it deterministically.
|
||||
|
||||
### Loom's limits
|
||||
|
||||
- Slow. Each `loom::model` invocation explores many schedules; keep tests tiny (2-3 threads, a few operations each).
|
||||
- Single-machine only. Doesn't model distributed systems.
|
||||
- Doesn't catch UB inside `unsafe` blocks the way miri does. **Run both: miri for memory safety, loom for thread schedules.**
|
||||
- Doesn't handle `tokio` directly. Loom replaces stdlib's sync primitives; tokio's are independent.
|
||||
|
||||
## Send and Sync — what they mean
|
||||
|
||||
- `T: Send` — `T` can be moved between threads safely.
|
||||
- `T: Sync` — `&T` can be shared between threads safely.
|
||||
|
||||
These are auto-derived for composite types if all components implement them. Manual `unsafe impl Send/Sync` is required only for raw pointer types and FFI handles.
|
||||
|
||||
```rust
|
||||
struct MyHandle { raw: *mut FfiObject }
|
||||
|
||||
// SAFETY: FfiObject's documented contract states that move-between-threads
|
||||
// is safe as long as concurrent use is externally synchronized. We do not
|
||||
// implement Sync because the FFI object is single-threaded once obtained.
|
||||
unsafe impl Send for MyHandle {}
|
||||
// Do NOT impl Sync — the FFI is not thread-safe.
|
||||
```
|
||||
|
||||
When the compiler complains that "T: Send is not satisfied", the cause is usually a raw pointer, an `Rc` (not `Arc`), or a `RefCell` (use `Mutex`).
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Holding a `std::sync::Mutex` guard across `.await`.** Compiles, deadlocks at runtime under `current_thread`.
|
||||
2. **`Arc::clone` in a tight loop.** Refcount bump is cheap but not free; pass `&Arc<T>` when possible.
|
||||
3. **`Mutex<HashMap<K, V>>` for hot reads.** Switch to `RwLock` or `Arc<dashmap::DashMap>`.
|
||||
4. **Atomic operations with `Ordering::Relaxed` for happens-before publication.** You need `Release`/`Acquire`. Run under loom to be sure.
|
||||
5. **Unbounded channels.** Always set capacity. If you "know it won't backlog", you don't, and it will.
|
||||
6. **Spawning detached tokio tasks for fire-and-forget cleanup.** Use `JoinSet` so panics surface.
|
||||
7. **`std::mem::transmute` to fake `Send`/`Sync`.** Use `unsafe impl` with a SAFETY comment instead. Transmute breaks Stacked Borrows and miri.
|
||||
8. **Locking order inversion across two mutexes.** Always acquire in a globally consistent order. For more than three locks, switch to a single mutex around a struct.
|
||||
|
||||
## When to escape to lock-free
|
||||
|
||||
You should reach for atomics + `UnsafeCell` only when:
|
||||
1. The hot path is **measured** to be bottlenecked on lock contention.
|
||||
2. There is no existing library (crossbeam, atomic-queue, hazardous) that solves your problem.
|
||||
3. You can write loom tests that pass.
|
||||
4. You can write miri tests that pass.
|
||||
5. You have at least one other engineer who can review the algorithm.
|
||||
|
||||
Practically all "I want to write a lock-free queue" projects fail (3) or (4). When in doubt, take the lock and move on.
|
||||
@@ -0,0 +1,439 @@
|
||||
# Library Defaults — Full Decision Tree
|
||||
|
||||
The opinionated, audited-in-prod stack for 2026 Rust. Every entry has a one-line rationale and a canonical code snippet so the agent does not have to relearn each library's idioms.
|
||||
|
||||
## Async runtime — `tokio`
|
||||
|
||||
The default. Use `tokio` for new work. Multi-thread runtime unless you have a measured reason to go single-thread.
|
||||
|
||||
```rust
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
run().await
|
||||
}
|
||||
```
|
||||
|
||||
Avoid:
|
||||
- `async-std` — unmaintained, last release ages ago. crates.io download counts are misleading because of historical inertia.
|
||||
- `smol` — fine for embedded-ish niches; outside that, the ecosystem is on tokio.
|
||||
- Mixing runtimes in one binary. Pick one and stay.
|
||||
|
||||
## Errors — `anyhow` (apps) + `thiserror` (libs)
|
||||
|
||||
Application boundaries get `anyhow::Error` with `.context("...")` at every layer that adds meaning. Libraries expose `#[derive(thiserror::Error)]` enums with `#[non_exhaustive]`.
|
||||
|
||||
```rust
|
||||
// Application code
|
||||
use anyhow::Context as _;
|
||||
|
||||
pub async fn load_config(path: &Path) -> anyhow::Result<Config> {
|
||||
let text = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("reading config from {}", path.display()))?;
|
||||
let cfg: Config = toml::from_str(&text)
|
||||
.with_context(|| format!("parsing config at {}", path.display()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
// Library code
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ParseError {
|
||||
#[error("expected {expected}, found {found} at position {position}")]
|
||||
Mismatch { expected: &'static str, found: String, position: usize },
|
||||
#[error("unexpected end of input after {context}")]
|
||||
UnexpectedEof { context: &'static str },
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
`#[non_exhaustive]` on enums prevents downstream `match` from breaking when you add variants. `#[error(transparent)]` on a wrapper variant forwards Display + cause to the inner error.
|
||||
|
||||
## CLI — `clap` with derive
|
||||
|
||||
```rust
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to the config file
|
||||
#[arg(short, long, env = "MYAPP_CONFIG", default_value = "config.toml")]
|
||||
config: PathBuf,
|
||||
|
||||
/// Enable verbose output (-v, -vv, -vvv)
|
||||
#[arg(short, long, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Run the server
|
||||
Serve {
|
||||
#[arg(short, long, default_value_t = 8080)]
|
||||
port: u16,
|
||||
},
|
||||
/// Migrate the database
|
||||
Migrate {
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Avoid `structopt` (deprecated, merged into clap), `argh` (less ergonomic), `pico-args` (only when binary size matters more than DX).
|
||||
|
||||
## Logging — `tracing` + `tracing-subscriber`
|
||||
|
||||
Not `log` + `env_logger`. `tracing` supports spans (structured context that follows async tasks) and structured fields - `log` cannot.
|
||||
|
||||
```rust
|
||||
use tracing::{info, instrument, warn, Level};
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn"));
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.with_thread_ids(true)
|
||||
.with_line_number(true)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
#[instrument(skip(db), fields(user_id = %user.id))]
|
||||
async fn process_user(db: &Pool, user: &User) -> anyhow::Result<()> {
|
||||
info!("processing user");
|
||||
if user.is_banned() {
|
||||
warn!(reason = "banned", "skipping");
|
||||
return Ok(());
|
||||
}
|
||||
// ... body ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Replace `println!` with `info!`/`warn!`/`error!`. Replace `eprintln!` with `tracing::error!`.
|
||||
|
||||
## Error reporting (binaries) — `color-eyre`
|
||||
|
||||
For binary `main()`, hook `color-eyre` to give pretty panics + nice `Result` printing:
|
||||
|
||||
```rust
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
tracing_subscriber::fmt::init();
|
||||
real_main()
|
||||
}
|
||||
```
|
||||
|
||||
Library code stays on `anyhow`/`thiserror`. `color-eyre` is purely a display layer for the binary.
|
||||
|
||||
## Serialization — `serde` + `serde_json`
|
||||
|
||||
The default for any data crossing a process boundary (file, network, IPC, database column).
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
pub struct ApiResponse {
|
||||
pub user_id: UserId,
|
||||
pub created_at: jiff::Timestamp,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
```
|
||||
|
||||
`deny_unknown_fields` catches typos in inputs. `rename_all = "snake_case"` aligns with REST/JSON conventions while keeping idiomatic Rust field names. `#[serde(flatten)]` for forward-compatible extra fields.
|
||||
|
||||
Alternatives:
|
||||
- `serde_yaml` (YAML — note: YAML's "deserialize anything" surface is a security trap; prefer JSON/TOML where possible)
|
||||
- `toml` (config files)
|
||||
- `rmp-serde` (MessagePack — binary, fast)
|
||||
- `ciborium` (CBOR)
|
||||
- `bincode 2` (binary, smaller; no serde required in v2 but interop fine)
|
||||
|
||||
## HTTP client — `reqwest`
|
||||
|
||||
```rust
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
|
||||
.https_only(true)
|
||||
.pool_max_idle_per_host(8)
|
||||
.build()?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Repo { full_name: String, stargazers_count: u64 }
|
||||
|
||||
let repo: Repo = client
|
||||
.get("https://api.github.com/repos/rust-lang/rust")
|
||||
.send().await?
|
||||
.error_for_status()?
|
||||
.json().await?;
|
||||
```
|
||||
|
||||
`error_for_status()?` turns 4xx/5xx into `Err`. Always include a User-Agent. `https_only(true)` is a soundness toggle - prevents accidental http:// downgrade.
|
||||
|
||||
## Web framework — `axum`
|
||||
|
||||
```rust
|
||||
use axum::{Router, routing::get, extract::State, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState { db: sqlx::PgPool }
|
||||
|
||||
async fn health(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
let ok = sqlx::query_scalar!("SELECT 1::int4").fetch_one(&state.db).await.is_ok();
|
||||
Json(serde_json::json!({ "ok": ok }))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let state = Arc::new(AppState { db: sqlx::PgPool::connect(&env_db()).await? });
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.with_state(state)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.layer(tower_http::compression::CompressionLayer::new());
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Avoid `actix-web` (legacy patterns, separate runtime model), `warp` (filter explosion in non-trivial apps), `rocket` (slow release cadence). Pair `axum` with `tower-http` for middleware (trace, compression, CORS, timeout, request-id).
|
||||
|
||||
## Database — `sqlx` (compile-time checked SQL)
|
||||
|
||||
```rust
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct User { pub id: uuid::Uuid, pub email: String, pub created_at: jiff::Timestamp }
|
||||
|
||||
pub async fn find_user(pool: &PgPool, email: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id, email, created_at as "created_at: jiff::Timestamp"
|
||||
FROM users WHERE email = $1"#,
|
||||
email
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
`query_as!` checks the SQL against the live database at compile time. To work without a live DB during builds, generate offline metadata: `cargo sqlx prepare`. Commit the resulting `.sqlx/` directory.
|
||||
|
||||
Avoid `diesel` (sync-first, heavy DSL), raw `tokio-postgres` (loses type checks), `sea-orm` (more magic, less control).
|
||||
|
||||
For migrations: `sqlx migrate add <name>` + `sqlx::migrate!("./migrations").run(&pool).await?`.
|
||||
|
||||
## Time — `jiff`
|
||||
|
||||
The 2025+ choice. Single crate, sane defaults, civil time / instant / span distinction.
|
||||
|
||||
```rust
|
||||
use jiff::{Timestamp, Span, ToSpan, Zoned};
|
||||
|
||||
let now: Timestamp = Timestamp::now();
|
||||
let in_one_hour = now.checked_add(1.hour())?;
|
||||
let local: Zoned = now.in_tz("Asia/Seoul")?;
|
||||
let span: Span = local - some_earlier.in_tz("Asia/Seoul")?;
|
||||
```
|
||||
|
||||
Avoid `chrono` (old API, generic-heavy, time zone story still painful), `time` crate (split ecosystem, weaker docs). `jiff` is the post-`chrono` consolidation.
|
||||
|
||||
## UUID — `uuid` with v7
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
|
||||
// v7 for IDs (sortable, time-ordered, monotonic-ish, RFC 9562)
|
||||
let id = Uuid::now_v7();
|
||||
```
|
||||
|
||||
v4 is fine for nonces, v7 for primary keys (better index locality). Never v1 (leaks MAC). Cargo features: `uuid = { version = "1", features = ["v4", "v7", "serde"] }`.
|
||||
|
||||
## DataFrames / analytics — `polars`
|
||||
|
||||
For columnar data, joins, group-by, lazy plans:
|
||||
|
||||
```rust
|
||||
use polars::prelude::*;
|
||||
|
||||
let df = LazyCsvReader::new("events.csv")
|
||||
.finish()?
|
||||
.group_by([col("user_id")])
|
||||
.agg([col("amount").sum().alias("total")])
|
||||
.sort(["total"], Default::default())
|
||||
.collect()?;
|
||||
```
|
||||
|
||||
The Rust API mirrors the Python one. Use the lazy API by default; materialize with `.collect()` at the end.
|
||||
|
||||
## Channels
|
||||
|
||||
- Single-producer single-consumer or bounded MPSC → `tokio::sync::mpsc` (async) or `flume` (sync + async).
|
||||
- Broadcast → `tokio::sync::broadcast`.
|
||||
- Watch (latest-value pubsub) → `tokio::sync::watch`.
|
||||
- Oneshot → `tokio::sync::oneshot`.
|
||||
|
||||
Avoid raw `std::sync::mpsc` (sync only, fewer features), `crossbeam-channel` (good but heavier; use only if you need rendezvous semantics).
|
||||
|
||||
## Coordinate spaces / 2D math — `euclid`
|
||||
|
||||
```rust
|
||||
use euclid::{Point2D, Size2D, default::Box2D};
|
||||
struct ScreenSpace;
|
||||
struct WorldSpace;
|
||||
|
||||
type ScreenPoint = Point2D<f32, ScreenSpace>;
|
||||
type WorldPoint = Point2D<f32, WorldSpace>;
|
||||
|
||||
let cursor: ScreenPoint = Point2D::new(120.0, 240.0);
|
||||
let player: WorldPoint = Point2D::new(3.5, 1.2);
|
||||
|
||||
// let mistake = cursor + player; // ❌ type error
|
||||
```
|
||||
|
||||
Generalize the pattern to your own domains (see `references/type-state.md`).
|
||||
|
||||
## Property tests — `proptest`
|
||||
|
||||
```rust
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn parse_roundtrips(s in r"[a-zA-Z0-9_-]{1,50}") {
|
||||
let parsed = parse(&s).unwrap();
|
||||
let back = parsed.to_string();
|
||||
prop_assert_eq!(back, s);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avoid `quickcheck` (older, less ergonomic). proptest gives shrinking + regression corpus + integration with `criterion`.
|
||||
|
||||
## Snapshot tests — `insta`
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn renders_help() {
|
||||
let output = render(&example_input());
|
||||
insta::assert_snapshot!(output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_well() {
|
||||
insta::assert_json_snapshot!(serializable_value());
|
||||
}
|
||||
```
|
||||
|
||||
`cargo insta review` (after `cargo install cargo-insta`) — interactive review of changed snapshots.
|
||||
|
||||
## Benchmarks — `criterion`
|
||||
|
||||
Stable Rust friendly (no nightly `#[bench]`).
|
||||
|
||||
```rust
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
|
||||
fn bench_parse(c: &mut Criterion) {
|
||||
let input = std::fs::read_to_string("samples/large.txt").unwrap();
|
||||
c.bench_function("parse_large", |b| b.iter(|| parse(black_box(&input))));
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_parse);
|
||||
criterion_main!(benches);
|
||||
```
|
||||
|
||||
Run with `cargo bench`. HTML reports under `target/criterion/`. Pair with `cargo bench -- --save-baseline main` then `--baseline main` for comparison.
|
||||
|
||||
## Concurrency model — `loom`
|
||||
|
||||
For lock-free or atomic-heavy code (channels, refcounts, hazard pointers). See `references/concurrency.md` for the full pattern.
|
||||
|
||||
## Arena allocator — `bumpalo`
|
||||
|
||||
```rust
|
||||
use bumpalo::Bump;
|
||||
|
||||
let bump = Bump::new();
|
||||
let node = bump.alloc(Node { value: 42, next: None });
|
||||
let s: &str = bump.alloc_str("hello");
|
||||
// All allocations freed at once when `bump` drops.
|
||||
```
|
||||
|
||||
For parser nodes, AST construction, per-request scratch. Outperforms heap allocation for short-lived owned data by an order of magnitude.
|
||||
|
||||
## Web client (browser, WASM-bound) — `gloo` ecosystem
|
||||
|
||||
If targeting WASM browser, use `gloo-net` for fetch and `gloo-storage` for localStorage; not `web-sys` directly unless you need DOM-level APIs.
|
||||
|
||||
## Lazy statics — `std::sync::LazyLock` (since 1.80)
|
||||
|
||||
```rust
|
||||
use std::sync::LazyLock;
|
||||
static CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load_from_env().unwrap());
|
||||
```
|
||||
|
||||
Avoid `lazy_static!` (macro-heavy, predates std), `once_cell` (now in std as `LazyLock`/`OnceLock`).
|
||||
|
||||
## Hash maps — `std::collections::HashMap` + `ahash` for hot paths
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use ahash::RandomState;
|
||||
|
||||
type FastMap<K, V> = HashMap<K, V, RandomState>;
|
||||
let mut counters: FastMap<String, u64> = FastMap::default();
|
||||
```
|
||||
|
||||
`HashMap` defaults to SipHash (DoS-resistant). For internal hot loops where you trust the keys, `ahash` is 2-5x faster.
|
||||
|
||||
For sorted iteration, use `BTreeMap`. For small keys with known small N, `Vec<(K, V)>` may beat both.
|
||||
|
||||
## File I/O — `tokio::fs` (async) or `std::fs` (sync utility)
|
||||
|
||||
```rust
|
||||
let contents = tokio::fs::read_to_string("data.json").await?;
|
||||
```
|
||||
|
||||
For large files: `tokio::fs::File` + `tokio::io::BufReader`. For random access, `memmap2` (with the unsafe-discipline wrappers).
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Need to ship the thing?
|
||||
├── HTTP server → axum + sqlx + tracing + jiff + tokio
|
||||
├── HTTP client → reqwest (+ tokio)
|
||||
├── CLI → clap + color-eyre + tracing + indicatif (progress) + dialoguer (prompts)
|
||||
├── TUI → ratatui + crossterm
|
||||
├── Background worker → tokio + ETL → polars + duckdb
|
||||
├── Game / graphics → wgpu + winit + euclid (or bevy if you want the engine)
|
||||
├── WASM front-end → leptos (or dioxus / yew) + wasm-bindgen + gloo
|
||||
├── Embedded → embassy (async on bare metal)
|
||||
├── FFI to C / Python → cxx (C++) / pyo3 (Python) / cbindgen (header gen)
|
||||
└── Just a script → rust-script (see one-liners.md)
|
||||
```
|
||||
|
||||
When in doubt, search crates.io for the latest version, then check:
|
||||
1. Is it maintained? (`cargo deny check` will scream if it's yanked or unmaintained)
|
||||
2. Does it have `serde` feature? (boundary types should always serde)
|
||||
3. Does it have `tokio` integration? (avoid runtime mixing)
|
||||
4. Is it on `tokio::io::AsyncRead`/`AsyncWrite` (the std for async I/O)?
|
||||
5. Are there safety-critical `unsafe` regions? If yes, has the author shipped miri proofs?
|
||||
@@ -0,0 +1,291 @@
|
||||
# One-Liners and Disposable Scripts
|
||||
|
||||
Production hygiene with throwaway ergonomics. Rust scripts get the same strict lints, the same miri rule when `unsafe` is touched, the same type discipline. The difference is dependency declaration lives inline.
|
||||
|
||||
## `rust-script` — the recommended path
|
||||
|
||||
Install once:
|
||||
|
||||
```bash
|
||||
cargo install rust-script
|
||||
```
|
||||
|
||||
Write a script:
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! Fetch a URL and print its body length.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ./fetch.rs <url>
|
||||
//!
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! anyhow = "1"
|
||||
//! reqwest = { version = "0.12", features = ["blocking"] }
|
||||
//! ```
|
||||
|
||||
use std::env;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let url = env::args().nth(1).context("usage: fetch.rs <url>")?;
|
||||
let body = reqwest::blocking::get(&url)?.error_for_status()?.text()?;
|
||||
println!("{} bytes", body.len());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Make executable: `chmod +x fetch.rs`. Run: `./fetch.rs https://example.com`.
|
||||
|
||||
The `//! \`\`\`cargo` block is parsed as inline `Cargo.toml`. Everything else is normal Rust.
|
||||
|
||||
## With async
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! anyhow = "1"
|
||||
//! tokio = { version = "1", features = ["full"] }
|
||||
//! reqwest = "0.12"
|
||||
//! ```
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let urls = [
|
||||
"https://example.com",
|
||||
"https://example.org",
|
||||
];
|
||||
let client = reqwest::Client::new();
|
||||
let bodies = futures::future::join_all(urls.iter().map(|u| {
|
||||
let c = client.clone();
|
||||
async move { c.get(*u).send().await?.text().await }
|
||||
})).await;
|
||||
for (url, body) in urls.iter().zip(bodies) {
|
||||
match body {
|
||||
Ok(b) => println!("{url}: {} bytes", b.len()),
|
||||
Err(e) => eprintln!("{url}: error {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## With CLI parsing
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! anyhow = "1"
|
||||
//! clap = { version = "4", features = ["derive"] }
|
||||
//! ```
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "rename files by pattern")]
|
||||
struct Cli {
|
||||
/// Glob to match
|
||||
pattern: String,
|
||||
/// Replacement template (use {n} for sequence)
|
||||
template: String,
|
||||
/// Show what would happen without doing it
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let entries: Vec<_> = glob::glob(&cli.pattern)?.collect::<Result<_, _>>()?;
|
||||
for (n, entry) in entries.iter().enumerate() {
|
||||
let target = cli.template.replace("{n}", &n.to_string());
|
||||
if cli.dry_run {
|
||||
println!("{} -> {target}", entry.display());
|
||||
} else {
|
||||
std::fs::rename(entry, &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
`rust-script` caches the compiled binary in `~/.cache/rust-script/`. First run is slow (full compile), subsequent runs are instant.
|
||||
|
||||
To clear: `rust-script --clear-cache`.
|
||||
|
||||
Pin a script's compile target into the script directory for portability:
|
||||
|
||||
```bash
|
||||
rust-script --build-only --base-path . ./script.rs
|
||||
```
|
||||
|
||||
This drops a `target/` next to the script with the prebuilt binary.
|
||||
|
||||
## `cargo-script` (RFC 3424, stable since Rust 1.85)
|
||||
|
||||
The official replacement that landed in cargo proper. Same idea, slightly different syntax:
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env -S cargo +nightly -Zscript
|
||||
---
|
||||
package:
|
||||
name = "fetch"
|
||||
edition = "2024"
|
||||
|
||||
dependencies:
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
---
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let url = std::env::args().nth(1).context("url required")?;
|
||||
println!("{}", reqwest::blocking::get(&url)?.text()?.len());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Status as of 2026-05: stabilization in progress. Use `rust-script` for production now, migrate when `cargo script` is stable everywhere your tools live.
|
||||
|
||||
## Strict mode for scripts
|
||||
|
||||
Add a lints block in the inline `Cargo.toml`:
|
||||
|
||||
```rust
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! anyhow = "1"
|
||||
//!
|
||||
//! [lints.rust]
|
||||
//! unsafe_code = "forbid"
|
||||
//!
|
||||
//! [lints.clippy]
|
||||
//! all = "deny"
|
||||
//! pedantic = "warn"
|
||||
//! unwrap_used = "deny"
|
||||
//! expect_used = "deny"
|
||||
//! panic = "deny"
|
||||
//! ```
|
||||
```
|
||||
|
||||
Now the script gets the same strictness as the main project. If you need a one-line `unwrap()` for prototype velocity, switch the lint to `warn` for that one script - never blanket `allow`.
|
||||
|
||||
Run with lints visible:
|
||||
|
||||
```bash
|
||||
RUSTFLAGS="-D warnings" rust-script ./script.rs
|
||||
```
|
||||
|
||||
## When NOT to use a script
|
||||
|
||||
- It is going to live longer than a week → make it a real crate with `cargo new --bin`.
|
||||
- It needs custom build scripts (`build.rs`) → real crate.
|
||||
- It needs binary distribution to other machines → real crate with `cargo dist`.
|
||||
- It needs to be tested → real crate (scripts can technically run `#[test]`s under `cargo test`, but the workflow is awkward).
|
||||
|
||||
A reasonable migration path: start as a script, when complexity grows past ~200 lines or you reach for a second `.rs` file, run `rust-script --emit ./script.rs` to dump a regular Cargo project skeleton and continue from there.
|
||||
|
||||
## Inline tests in a script
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! ```
|
||||
|
||||
fn double(x: i32) -> i32 { x * 2 }
|
||||
|
||||
fn main() {
|
||||
println!("{}", double(21));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn doubles_ints() {
|
||||
assert_eq!(double(5), 10);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run tests: `rust-script --test ./script.rs`.
|
||||
|
||||
## A useful "Rust as awk" pattern
|
||||
|
||||
For ad-hoc data processing on stdin:
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! serde_json = "1"
|
||||
//! ```
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line?;
|
||||
let value: serde_json::Value = serde_json::from_str(&line)?;
|
||||
if let Some(s) = value.get("level").and_then(|v| v.as_str()) {
|
||||
if s == "error" {
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
`cat logs.jsonl | ./filter-errors.rs` — filter JSON logs by `level == "error"`. Faster than `jq` for big files, type-safe.
|
||||
|
||||
For numerics:
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env rust-script
|
||||
//! sum a column of numbers from stdin
|
||||
use std::io::{self, BufRead};
|
||||
fn main() {
|
||||
let total: f64 = io::stdin().lock().lines()
|
||||
.filter_map(|l| l.ok())
|
||||
.filter_map(|l| l.trim().parse::<f64>().ok())
|
||||
.sum();
|
||||
println!("{total}");
|
||||
}
|
||||
```
|
||||
|
||||
## The `rust-script` shebang trick on macOS
|
||||
|
||||
macOS does not support multi-arg shebangs without `env -S`. Use:
|
||||
|
||||
```rust
|
||||
#!/usr/bin/env -S rust-script --
|
||||
```
|
||||
|
||||
The `--` lets clap-style argument parsers see the user's args, not the rust-script arguments.
|
||||
|
||||
## Editor support
|
||||
|
||||
VS Code / Helix / Vim with `rust-analyzer`: open the script file as if it were `src/main.rs` of an inferred crate. Most editors auto-detect the inline manifest. If not, hand-create a `Cargo.toml` next to the script with matching deps for the duration of editing, then delete it.
|
||||
|
||||
## When `rust-script` is too heavy
|
||||
|
||||
For absolutely throwaway "one expression on stdin" use cases, a Rust REPL like `evcxr_jupyter` (Jupyter kernel) or `irust` (terminal REPL) is more appropriate:
|
||||
|
||||
```bash
|
||||
cargo install irust
|
||||
irust
|
||||
```
|
||||
|
||||
But these are interactive playgrounds, not scriptable. For shell pipelines, stay with `rust-script`.
|
||||
|
||||
## The Promise
|
||||
|
||||
Same strict lints. Same `clippy::pedantic` enforcement. Same `unsafe`-requires-SAFETY rule. The agent does not get a free pass on a 30-line script. The whole point of strict scripts is that **production hygiene is cheap when the toolchain enforces it**.
|
||||
@@ -0,0 +1,429 @@
|
||||
# Property Tests (proptest) + Snapshot Tests (insta)
|
||||
|
||||
Two test types every Rust project should have alongside unit tests. Proptest hunts for inputs your unit tests forgot to try. Insta locks down output shapes you do not want to silently change.
|
||||
|
||||
## When to reach for each
|
||||
|
||||
| Want to test… | Use |
|
||||
|---|---|
|
||||
| One specific behavior with a known input | `#[test]` + `assert_eq!` |
|
||||
| All inputs of a certain shape work | `proptest!` |
|
||||
| Output structure stays stable across refactors | `insta::assert_*_snapshot!` |
|
||||
| Parser/serializer round-trips | `proptest!` (the round-trip property) |
|
||||
| CLI help text, JSON response shape, debug output | `insta::assert_snapshot!` |
|
||||
| Concurrency under all interleavings | `loom` (see `concurrency.md`) |
|
||||
|
||||
Use all three. They cover different bug classes.
|
||||
|
||||
## Proptest — setup
|
||||
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
proptest-derive = "0.5" # for #[derive(Arbitrary)]
|
||||
```
|
||||
|
||||
`proptest.toml` at project root (optional, sane defaults):
|
||||
|
||||
```toml
|
||||
cases = 256 # number of random inputs per property
|
||||
max_local_rejects = 65536
|
||||
max_global_rejects = 1024
|
||||
max_shrink_iters = 1024
|
||||
max_shrink_time = 60_000 # ms
|
||||
failure_persistence = { source_file = "proptest-regressions/", file_name = "regressions.txt" }
|
||||
verbose = 0
|
||||
```
|
||||
|
||||
`failure_persistence` is the killer feature: every failure is written to a regression file. On the next run, those exact inputs are replayed first, so once a bug is found it never escapes again.
|
||||
|
||||
## Basic property test
|
||||
|
||||
```rust
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn parse(s: &str) -> Result<Color, ParseError> { /* ... */ }
|
||||
fn render(c: &Color) -> String { /* ... */ }
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn parse_render_roundtrips(red in 0u8..=255, green in 0u8..=255, blue in 0u8..=255) {
|
||||
let color = Color { red, green, blue };
|
||||
let rendered = render(&color);
|
||||
let parsed = parse(&rendered).expect("our render should always parse");
|
||||
prop_assert_eq!(parsed, color);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`proptest!` macro takes `(arg in strategy, ...)` pairs. Each strategy produces values; proptest runs the body with random samples, then shrinks failing cases to minimal forms.
|
||||
|
||||
## Strategies — the value-generation language
|
||||
|
||||
| Strategy | Produces |
|
||||
|---|---|
|
||||
| `any::<T>()` | Any value of `T` (if `T: Arbitrary`) |
|
||||
| `0u32..100` | Integer ranges |
|
||||
| `prop::sample::select(slice)` | Pick from a list |
|
||||
| `prop::collection::vec(elem, range)` | Vec of length in range |
|
||||
| `prop::collection::hash_map(k, v, n..m)` | HashMap |
|
||||
| `prop::option::of(strategy)` | Option |
|
||||
| `prop::result::maybe_ok(ok, err)` | Result |
|
||||
| `(s1, s2).prop_map(\|(a, b)\| ...)` | Combine, transform |
|
||||
| `s.prop_filter("reason", \|v\| pred)` | Reject values |
|
||||
| `s.prop_flat_map(\|v\| dependent)` | Sequential dependency |
|
||||
| `prop_oneof![strategy1, strategy2]` | Union of strategies |
|
||||
| `r"[a-z]{3,10}"` | Regex-generated string |
|
||||
| `"\\PC*"` | Any printable non-control string |
|
||||
|
||||
Example combining several:
|
||||
|
||||
```rust
|
||||
fn config_strategy() -> impl Strategy<Value = Config> {
|
||||
(
|
||||
prop::sample::select(vec!["dev", "staging", "prod"]),
|
||||
0u16..=65535,
|
||||
prop::collection::hash_map(
|
||||
r"[a-z_]{1,20}",
|
||||
any::<String>(),
|
||||
0..5,
|
||||
),
|
||||
).prop_map(|(env, port, vars)| Config {
|
||||
env: env.into(),
|
||||
port,
|
||||
env_vars: vars,
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn config_validates(cfg in config_strategy()) {
|
||||
let result = validate(&cfg);
|
||||
if cfg.port == 0 {
|
||||
prop_assert!(result.is_err());
|
||||
} else {
|
||||
prop_assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Properties to write for every parser
|
||||
|
||||
1. **Round-trip:** `parse(render(x)) == x` for all valid `x`.
|
||||
2. **No-panic:** `parse(arbitrary_string)` never panics, always returns `Result`.
|
||||
3. **Idempotent:** `parse(parse(x).unwrap().render()) == parse(x).unwrap()`.
|
||||
4. **Whitespace insensitivity:** `parse(x) == parse(strip_whitespace(x))` (if applicable).
|
||||
|
||||
For every serializer:
|
||||
|
||||
1. **Length bound:** `render(x).len() <= bound(x)`.
|
||||
2. **Charset:** `render(x).chars().all(|c| ALLOWED.contains(&c))`.
|
||||
|
||||
For every collection operation:
|
||||
|
||||
1. **Identity:** `op_identity(x) == x` (sort an already-sorted, dedupe a unique).
|
||||
2. **Idempotence:** `op(op(x)) == op(x)`.
|
||||
3. **Commutativity:** `op(a, b) == op(b, a)` (set union, etc).
|
||||
4. **Length:** `op(a, b).len() == known_relation(a.len(), b.len())`.
|
||||
|
||||
For every numeric op:
|
||||
|
||||
1. **Monotonicity:** `a <= b => f(a) <= f(b)`.
|
||||
2. **Identity element:** `f(x, identity) == x`.
|
||||
|
||||
Write these mechanically. The agent should reach for proptest the moment any of these properties is checkable.
|
||||
|
||||
## Derive `Arbitrary`
|
||||
|
||||
```rust
|
||||
use proptest_derive::Arbitrary;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Arbitrary)]
|
||||
struct Vec3 {
|
||||
#[proptest(strategy = "-100.0..=100.0")]
|
||||
x: f32,
|
||||
#[proptest(strategy = "-100.0..=100.0")]
|
||||
y: f32,
|
||||
#[proptest(strategy = "-100.0..=100.0")]
|
||||
z: f32,
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn dot_product_is_commutative(a: Vec3, b: Vec3) {
|
||||
prop_assert!((dot(&a, &b) - dot(&b, &a)).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`#[derive(Arbitrary)]` auto-implements the strategy. Per-field `#[proptest(strategy = "...")]` overrides.
|
||||
|
||||
## Stateful / state machine tests
|
||||
|
||||
For data structures with operations (queues, maps, trees), use `proptest-state-machine`:
|
||||
|
||||
```rust
|
||||
use proptest_state_machine::{ReferenceStateMachine, StateMachineTest};
|
||||
|
||||
struct MyQueueRef { state: VecDeque<i32> }
|
||||
struct MyQueueSut { sut: MyQueue<i32> }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Op { Push(i32), Pop }
|
||||
|
||||
impl ReferenceStateMachine for MyQueueRef {
|
||||
type State = VecDeque<i32>;
|
||||
type Transition = Op;
|
||||
|
||||
fn init_state() -> BoxedStrategy<Self::State> {
|
||||
Just(VecDeque::new()).boxed()
|
||||
}
|
||||
fn transitions(_: &Self::State) -> BoxedStrategy<Self::Transition> {
|
||||
prop_oneof![
|
||||
any::<i32>().prop_map(Op::Push),
|
||||
Just(Op::Pop),
|
||||
].boxed()
|
||||
}
|
||||
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
|
||||
match transition {
|
||||
Op::Push(x) => state.push_back(*x),
|
||||
Op::Pop => { state.pop_front(); }
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl StateMachineTest for MyQueueSut {
|
||||
type SystemUnderTest = MyQueue<i32>;
|
||||
type Reference = MyQueueRef;
|
||||
|
||||
fn init_test(_: &<Self::Reference as ReferenceStateMachine>::State) -> Self::SystemUnderTest {
|
||||
MyQueue::new()
|
||||
}
|
||||
fn apply(mut sut: Self::SystemUnderTest, _: &VecDeque<i32>, transition: Op) -> Self::SystemUnderTest {
|
||||
match transition {
|
||||
Op::Push(x) => sut.push(x),
|
||||
Op::Pop => { sut.pop(); }
|
||||
}
|
||||
sut
|
||||
}
|
||||
fn check_invariants(sut: &Self::SystemUnderTest, state: &VecDeque<i32>) {
|
||||
assert_eq!(sut.len(), state.len());
|
||||
// also check head/tail/iteration order...
|
||||
}
|
||||
}
|
||||
|
||||
proptest_state_machine::prop_state_machine! {
|
||||
#[test]
|
||||
fn queue_matches_vecdeque(sequential 1..50 => MyQueueSut);
|
||||
}
|
||||
```
|
||||
|
||||
You define a reference implementation (`VecDeque` here), proptest fuzzes operations against both, asserts invariants every step. This is the technique for finding bugs in lock-free or complex containers.
|
||||
|
||||
## Shrinking
|
||||
|
||||
When a property fails, proptest reduces the input to a minimal counter-example. For built-in strategies this is automatic. For custom strategies built with `prop_map`, shrinking goes through the underlying strategy. Avoid breaking shrinking with `prop_filter` (rejection sampling) over wide spaces; prefer `prop_flat_map` or directly-shaped strategies.
|
||||
|
||||
## Regression corpus
|
||||
|
||||
When a property test fails, proptest writes the failing input to `proptest-regressions/<test_name>.txt`. Commit this directory. Future runs replay these failing inputs first, so the bug stays fixed forever.
|
||||
|
||||
```
|
||||
proptest-regressions/
|
||||
└── parse_color.txt # commit this
|
||||
```
|
||||
|
||||
## Insta — setup
|
||||
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
insta = { version = "1", features = ["yaml", "json", "redactions", "filters"] }
|
||||
|
||||
[dependencies.serde_yaml]
|
||||
version = "0.9"
|
||||
optional = true
|
||||
```
|
||||
|
||||
Install the CLI:
|
||||
|
||||
```bash
|
||||
cargo install cargo-insta
|
||||
```
|
||||
|
||||
## Insta — basic snapshots
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn renders_default_help() {
|
||||
let output = render_help();
|
||||
insta::assert_snapshot!(output);
|
||||
}
|
||||
```
|
||||
|
||||
First run: creates `src/snapshots/mycrate__renders_default_help.snap.new`. Run `cargo insta review`, press `a` to accept, the `.new` extension is dropped. Subsequent runs diff against the committed snapshot; mismatches fail the test.
|
||||
|
||||
## Insta — typed snapshots
|
||||
|
||||
```rust
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct Result {
|
||||
status: String,
|
||||
user: User,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_response() {
|
||||
let value = compute();
|
||||
insta::assert_json_snapshot!(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_response() {
|
||||
insta::assert_yaml_snapshot!(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_repr() {
|
||||
insta::assert_debug_snapshot!(value);
|
||||
}
|
||||
```
|
||||
|
||||
Choose:
|
||||
- `assert_snapshot!` for `String`/`Display` output (CLI help, error messages, generated code).
|
||||
- `assert_debug_snapshot!` for `{:?}` (Rust-internal data).
|
||||
- `assert_json_snapshot!` for structured data crossing process boundaries.
|
||||
- `assert_yaml_snapshot!` when YAML is easier to read in diffs.
|
||||
|
||||
## Insta — redactions and filters
|
||||
|
||||
For values that change every run (timestamps, UUIDs, paths):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn with_redactions() {
|
||||
let value = ApiResponse {
|
||||
id: uuid::Uuid::now_v7(),
|
||||
created_at: jiff::Timestamp::now(),
|
||||
body: "hello".into(),
|
||||
};
|
||||
insta::assert_json_snapshot!(value, {
|
||||
".id" => "[uuid]",
|
||||
".created_at" => "[timestamp]",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For regex filters applied to all snapshots in a test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn with_filters() {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.add_filter(r"/tmp/[a-z0-9-]+", "[TMP]");
|
||||
settings.add_filter(r"\d+\.\d+ms", "[TIMING]");
|
||||
settings.bind(|| {
|
||||
let output = run_command();
|
||||
insta::assert_snapshot!(output);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`Settings::bind` scopes filters to the closure.
|
||||
|
||||
## Insta workflow
|
||||
|
||||
1. Write the test, run it. First run creates `.snap.new`.
|
||||
2. `cargo insta review` → interactive UI. Show diff, accept/reject.
|
||||
3. Accepted snapshots commit to the repo.
|
||||
4. Refactor code. Tests run; mismatches show as diffs.
|
||||
5. If the new output is correct, `cargo insta accept` (or selective `review`). If wrong, fix the code.
|
||||
|
||||
Pair with CI to fail builds when uncommitted `.snap.new` files exist:
|
||||
|
||||
```bash
|
||||
cargo nextest run
|
||||
if find . -name "*.snap.new" | grep -q .; then
|
||||
echo "Pending snapshots, run 'cargo insta review'"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Inline snapshots
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn small_output() {
|
||||
let value = compute();
|
||||
insta::assert_snapshot!(value, @"hello world");
|
||||
}
|
||||
```
|
||||
|
||||
The trailing `@"..."` string is the expected snapshot, stored in source. Useful when the value is short enough that pulling out a separate file is overkill. `cargo insta accept` updates them in-place.
|
||||
|
||||
## Inline JSON snapshots
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn json_inline() {
|
||||
insta::assert_json_snapshot!(value, @r###"
|
||||
{
|
||||
"status": "ok",
|
||||
"count": 3
|
||||
}
|
||||
"###);
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
1. **Snapshots of unstable output.** If `HashMap` iteration order changes per run, snapshots will fail. Switch to `BTreeMap` or sort before snapshotting.
|
||||
2. **Massive snapshots.** A 10KB JSON dump where you really care about 3 fields. Either narrow to the fields, or accept that any refactor will require re-reviewing 10KB.
|
||||
3. **Snapshots that bake in implementation details.** "function called 3 times" is not a snapshot - it's a behavior assertion. Use a real assertion.
|
||||
4. **Skipping `cargo insta review`.** Accepting blind via `cargo insta accept --all` defeats the purpose. Always review.
|
||||
|
||||
## Combining proptest + insta
|
||||
|
||||
```rust
|
||||
proptest! {
|
||||
#[test]
|
||||
fn random_inputs_render_consistently(input: ValidInput) {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_snapshot_suffix(format!("{}", input.hash()));
|
||||
settings.bind(|| {
|
||||
insta::assert_snapshot!(render(&input));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
But honestly, this is rarely a fit. Proptest tests properties, insta tests output shape. Don't snapshot random inputs - that defeats both tools.
|
||||
|
||||
## CI matrix recommendation
|
||||
|
||||
```yaml
|
||||
- name: Tests
|
||||
run: cargo nextest run --all-features
|
||||
|
||||
- name: Property regressions (replay)
|
||||
run: |
|
||||
# The regression files in proptest-regressions/ replay first.
|
||||
# Failures here mean a previously-fixed bug came back.
|
||||
cargo nextest run --all-features --test-threads 1
|
||||
```
|
||||
|
||||
When a proptest finds a new failure, the regression file appears as a git diff - check it in.
|
||||
|
||||
## What proptest cannot do
|
||||
|
||||
- Find bugs that require multi-process / multi-network coordination → integration tests + fault injection.
|
||||
- Find concurrency bugs → use `loom` (see `concurrency.md`).
|
||||
- Find performance regressions → use `criterion`.
|
||||
|
||||
But for any function with a domain (inputs to outputs), proptest can find more bugs than your unit tests. **Write the property first, derive the unit test second.**
|
||||
@@ -0,0 +1,354 @@
|
||||
# Type-State and Newtype Patterns
|
||||
|
||||
The single highest-leverage thing Rust gives a coding agent: encode invariants in the type system so the compiler refuses incorrect code. The agent does not have to "remember" rules - the rules are physical.
|
||||
|
||||
## The Two Core Patterns
|
||||
|
||||
1. **Newtype wrappers for distinct semantic units.** Money, IDs, byte offsets, coordinate spaces - each gets its own tuple struct. The agent cannot pass meters where feet are expected, even though both are `f64` under the hood. This is the `euclid::Point<Screen>` vs `euclid::Point<World>` example Chris Allen called out.
|
||||
2. **Type-state for state machines.** Instead of a struct with a `status: enum { Draft, Validated, Persisted }` field and methods that check `if self.status == ...`, model each state as its own type. Transitions are method calls that consume `self` and return a new type. Illegal transitions become unrepresentable.
|
||||
|
||||
## Newtype Wrapper Cookbook
|
||||
|
||||
### Domain IDs
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct UserId(Uuid);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProductId(Uuid);
|
||||
|
||||
impl UserId {
|
||||
pub fn new() -> Self { Self(Uuid::now_v7()) }
|
||||
pub fn as_uuid(&self) -> &Uuid { &self.0 }
|
||||
}
|
||||
|
||||
impl ProductId {
|
||||
pub fn new() -> Self { Self(Uuid::now_v7()) }
|
||||
pub fn as_uuid(&self) -> &Uuid { &self.0 }
|
||||
}
|
||||
|
||||
// `fn buy(user: UserId, product: ProductId)` cannot be called with arguments swapped.
|
||||
```
|
||||
|
||||
`#[serde(transparent)]` keeps JSON/SQL round-trips identical to a bare `Uuid` - the wrapper is purely a compile-time discipline.
|
||||
|
||||
### Quantities with Phantom Type Tags
|
||||
|
||||
```rust
|
||||
use core::marker::PhantomData;
|
||||
use core::ops::{Add, Sub, Mul};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quantity<Unit> {
|
||||
raw: f64,
|
||||
_unit: PhantomData<Unit>,
|
||||
}
|
||||
|
||||
// Tag types - zero-sized, never instantiated.
|
||||
pub struct Meters;
|
||||
pub struct Feet;
|
||||
pub struct Seconds;
|
||||
|
||||
impl<U> Quantity<U> {
|
||||
pub const fn new(value: f64) -> Self {
|
||||
Self { raw: value, _unit: PhantomData }
|
||||
}
|
||||
pub fn raw(self) -> f64 { self.raw }
|
||||
}
|
||||
|
||||
// Adding same-unit quantities: allowed.
|
||||
impl<U> Add for Quantity<U> {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self { Self::new(self.raw + rhs.raw) }
|
||||
}
|
||||
|
||||
// Subtraction: allowed.
|
||||
impl<U> Sub for Quantity<U> {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self { Self::new(self.raw - rhs.raw) }
|
||||
}
|
||||
|
||||
// Multiplying by scalar: allowed.
|
||||
impl<U> Mul<f64> for Quantity<U> {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: f64) -> Self { Self::new(self.raw * rhs) }
|
||||
}
|
||||
|
||||
// Conversions are explicit, named methods - never `From`/`Into` between units.
|
||||
impl Quantity<Meters> {
|
||||
pub fn to_feet(self) -> Quantity<Feet> {
|
||||
Quantity::new(self.raw * 3.280_84)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
```rust
|
||||
let distance: Quantity<Meters> = Quantity::new(100.0);
|
||||
let height: Quantity<Feet> = Quantity::new(50.0);
|
||||
let combined = distance + height; // ❌ compile error
|
||||
let combined = distance + height.to_feet().to_meters_oops(); // ❌ no such method
|
||||
let combined = distance + distance; // ✅
|
||||
```
|
||||
|
||||
The agent cannot accidentally mix units. Refactors that change a quantity's underlying unit are caught at compile time everywhere the type flows.
|
||||
|
||||
### Byte Offsets vs Character Offsets
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ByteOffset(pub u32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CharOffset(pub u32);
|
||||
|
||||
impl ByteOffset {
|
||||
pub fn add(self, delta: u32) -> Self { Self(self.0 + delta) }
|
||||
}
|
||||
|
||||
// Converting between them is a function on the actual text.
|
||||
pub fn byte_to_char(text: &str, byte: ByteOffset) -> Option<CharOffset> {
|
||||
text.get(..byte.0 as usize).map(|prefix| CharOffset(prefix.chars().count() as u32))
|
||||
}
|
||||
```
|
||||
|
||||
A function signature `fn slice(text: &str, start: ByteOffset, end: ByteOffset)` cannot be called with character offsets. The UTF-8 boundary bug is now a compile error.
|
||||
|
||||
### Currency
|
||||
|
||||
```rust
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub struct Krw;
|
||||
pub struct Usd;
|
||||
pub struct Jpy;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Money<Currency> {
|
||||
amount: Decimal,
|
||||
_ccy: PhantomData<Currency>,
|
||||
}
|
||||
|
||||
impl<C> Money<C> {
|
||||
pub const fn new(amount: Decimal) -> Self { Self { amount, _ccy: PhantomData } }
|
||||
}
|
||||
|
||||
impl<C> Add for Money<C> {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self { Self::new(self.amount + rhs.amount) }
|
||||
}
|
||||
|
||||
// No blanket From<Money<X>> for Money<Y> - conversions go through an explicit
|
||||
// FX rate function that takes a `Rate<From, To>` argument.
|
||||
|
||||
pub struct Rate<From, To> {
|
||||
factor: Decimal,
|
||||
_from: PhantomData<From>,
|
||||
_to: PhantomData<To>,
|
||||
}
|
||||
|
||||
impl<From, To> Money<From> {
|
||||
pub fn convert(self, rate: Rate<From, To>) -> Money<To> {
|
||||
Money::new(self.amount * rate.factor)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The agent cannot add KRW and USD by accident. They cannot convert without a rate. They cannot apply a USD→JPY rate to a KRW value.
|
||||
|
||||
### Paths Rooted at Different Bases
|
||||
|
||||
```rust
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A path guaranteed to be relative to the project root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectRel(PathBuf);
|
||||
|
||||
/// A path guaranteed to be relative to the user's home directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomeRel(PathBuf);
|
||||
|
||||
impl ProjectRel {
|
||||
pub fn new(path: impl AsRef<Path>) -> Result<Self, PathError> {
|
||||
let path = path.as_ref();
|
||||
if path.is_absolute() { return Err(PathError::NotRelative); }
|
||||
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
|
||||
return Err(PathError::EscapesRoot);
|
||||
}
|
||||
Ok(Self(path.to_path_buf()))
|
||||
}
|
||||
|
||||
pub fn resolve(&self, project_root: &Path) -> PathBuf {
|
||||
project_root.join(&self.0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The agent's path-handling code now distinguishes between project-relative and home-relative paths at the type level. A function taking `ProjectRel` cannot be called with a `HomeRel`.
|
||||
|
||||
## Type-State State Machines
|
||||
|
||||
Encode the lifecycle of a value as a sequence of types. Each transition consumes the previous state and returns the next.
|
||||
|
||||
### HTTP Request Builder
|
||||
|
||||
```rust
|
||||
pub struct RequestBuilder<State> {
|
||||
url: String,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
pub struct NeedsUrl;
|
||||
pub struct NeedsMethod;
|
||||
pub struct Ready;
|
||||
|
||||
impl RequestBuilder<NeedsUrl> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: String::new(),
|
||||
method: Method::GET,
|
||||
headers: HeaderMap::new(),
|
||||
body: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
pub fn url(mut self, url: impl Into<String>) -> RequestBuilder<NeedsMethod> {
|
||||
self.url = url.into();
|
||||
RequestBuilder { url: self.url, method: self.method, headers: self.headers, body: self.body, _state: PhantomData }
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestBuilder<NeedsMethod> {
|
||||
pub fn method(mut self, method: Method) -> RequestBuilder<Ready> {
|
||||
self.method = method;
|
||||
RequestBuilder { url: self.url, method: self.method, headers: self.headers, body: self.body, _state: PhantomData }
|
||||
}
|
||||
}
|
||||
|
||||
// .header(), .body() available in any state that has at least URL.
|
||||
impl<S> RequestBuilder<S> {
|
||||
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
|
||||
self.headers.insert(name, value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// .send() only available once URL and method are set.
|
||||
impl RequestBuilder<Ready> {
|
||||
pub async fn send(self, client: &Client) -> reqwest::Result<Response> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
`client.send(RequestBuilder::new().send(...))` - compile error. The agent has to fill in the required steps. The IDE autocomplete also reflects only the legal next steps.
|
||||
|
||||
### File Handles
|
||||
|
||||
```rust
|
||||
pub struct File<State> {
|
||||
fd: RawFd,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
pub struct Open;
|
||||
pub struct Locked;
|
||||
pub struct Closed;
|
||||
|
||||
impl File<Open> {
|
||||
pub fn open(path: &Path) -> std::io::Result<Self> { /* ... */ }
|
||||
pub fn lock_exclusive(self) -> std::io::Result<File<Locked>> { /* flock */ }
|
||||
pub fn close(self) -> std::io::Result<File<Closed>> { /* ... */ }
|
||||
}
|
||||
|
||||
impl File<Locked> {
|
||||
pub fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { /* ... */ }
|
||||
pub fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { /* ... */ }
|
||||
pub fn unlock(self) -> std::io::Result<File<Open>> { /* ... */ }
|
||||
}
|
||||
|
||||
impl File<Closed> {
|
||||
// No methods. The type only exists to be dropped.
|
||||
}
|
||||
```
|
||||
|
||||
You cannot `read()` an unlocked file. You cannot `close()` while holding a lock without unlocking first. You cannot use a closed file at all - it has no methods.
|
||||
|
||||
## Sealed Traits
|
||||
|
||||
Sometimes you want a closed set of types implementing a trait, defined by the crate, not extensible by downstream. The sealed trait pattern:
|
||||
|
||||
```rust
|
||||
mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
pub trait Renderer: sealed::Sealed {
|
||||
fn render(&self, frame: &mut Frame);
|
||||
}
|
||||
|
||||
pub struct OpenGl;
|
||||
pub struct Vulkan;
|
||||
pub struct Metal;
|
||||
|
||||
impl sealed::Sealed for OpenGl {}
|
||||
impl sealed::Sealed for Vulkan {}
|
||||
impl sealed::Sealed for Metal {}
|
||||
|
||||
impl Renderer for OpenGl { fn render(&self, f: &mut Frame) { /* ... */ } }
|
||||
impl Renderer for Vulkan { fn render(&self, f: &mut Frame) { /* ... */ } }
|
||||
impl Renderer for Metal { fn render(&self, f: &mut Frame) { /* ... */ } }
|
||||
```
|
||||
|
||||
Downstream code cannot add new `impl Renderer for Whatever` because they cannot implement `sealed::Sealed` (its module is private). Useful when you want trait dispatch but maintain the invariant that you control all implementations.
|
||||
|
||||
## NonEmpty Collections
|
||||
|
||||
```rust
|
||||
pub struct NonEmptyVec<T> {
|
||||
head: T,
|
||||
tail: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("vector was empty")]
|
||||
pub struct Empty;
|
||||
|
||||
impl<T> NonEmptyVec<T> {
|
||||
pub fn try_from_vec(mut v: Vec<T>) -> Result<Self, Empty> {
|
||||
if v.is_empty() { return Err(Empty); }
|
||||
let tail = v.split_off(1);
|
||||
let head = v.into_iter().next().expect("checked non-empty");
|
||||
Ok(Self { head, tail })
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &T { &self.head }
|
||||
pub fn len(&self) -> usize { self.tail.len() + 1 }
|
||||
}
|
||||
```
|
||||
|
||||
Functions taking `NonEmptyVec<T>` cannot receive an empty vector. The `first()` method returns `&T`, not `Option<&T>`. The agent never has to write `match v.first() { Some(x) => ..., None => panic!(...) }` again.
|
||||
|
||||
## When NOT to Newtype
|
||||
|
||||
- For one-off internal computations where the unit lives in a single function and never crosses a boundary.
|
||||
- When the wrapper does not change behavior or invariants vs. the underlying type (e.g., a `struct Count(u32)` that is only ever used in one struct).
|
||||
- When `From`/`Into` conversions would be ergonomic but would defeat the purpose (if you find yourself wanting `impl From<UserId> for Uuid`, you do not want a newtype - you want a type alias).
|
||||
|
||||
The cost of a newtype is one tuple struct + the impls you need. The break-even is around three uses across different functions, or any use that crosses an API boundary.
|
||||
|
||||
## When NOT to Use Type-State
|
||||
|
||||
- When the state space is small and transitions are simple (`Option<T>` and `Result<T, E>` are state machines already).
|
||||
- When the type-state would force runtime branching upward (e.g., reading "should this run as Open or Locked?" from config means you store `Box<dyn FileLike>` anyway).
|
||||
- When the API is consumed by code that does not know the state at compile time (heterogeneous collections, dynamic dispatch boundaries).
|
||||
|
||||
In those cases, regular enum-tagged states are correct. The line is: **can the call site know the state statically?** If yes, type-state. If no, enum-with-tag.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Unsafe Discipline
|
||||
|
||||
The reason Chris Allen's "implementing a persistent memory arena in Rust was not hard" works: the unsafe surface area is microscopic, it lives behind one newtype with one constructor, and every block has a SAFETY comment that names a specific invariant. Coding agents follow the pattern mechanically once the shape is established.
|
||||
|
||||
## The Three Required Components
|
||||
|
||||
Every `unsafe` block needs all three. No exceptions.
|
||||
|
||||
1. **Safe wrapper.** No `unsafe fn` or raw pointer types in the crate's public API. If a caller needs to construct an instance, the constructor either does the work safely or is `unsafe` with a documented contract.
|
||||
2. **SAFETY comment.** A `// SAFETY:` line within 5 lines above the `unsafe { ... }` block, stating which invariant is upheld and where it comes from. Generic phrases ("this is safe because we checked") fail review.
|
||||
3. **miri proof.** A test that exercises the unsafe path under `cargo +nightly miri nextest run`. If the path cannot be exercised under miri (FFI, syscalls), provide an alternate proof and gate behind a feature flag ending in `-skip-miri`.
|
||||
|
||||
## The Wrapper Pattern (`NonNull<T>` style)
|
||||
|
||||
Reference the screenshot Chris Allen quoted - `std::ptr::NonNull<T>`. Mirror this shape for every raw pointer, raw slice, raw transmute, or uninit memory operation in your own code.
|
||||
|
||||
```rust
|
||||
use core::marker::PhantomData;
|
||||
use core::ptr::NonNull;
|
||||
|
||||
/// A non-null, properly aligned, initialized pointer that does not alias.
|
||||
///
|
||||
/// All invariants are upheld by [`Self::new`] (checked) or [`Self::new_unchecked`]
|
||||
/// (delegated to the caller's contract). Once you hold an `InitPtr<T>`, every
|
||||
/// public method on it is safe to call.
|
||||
#[derive(Debug)]
|
||||
#[repr(transparent)]
|
||||
pub struct InitPtr<T> {
|
||||
inner: NonNull<T>,
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
// Send/Sync are NOT automatic for raw-pointer-bearing types. Decide deliberately.
|
||||
// SAFETY: `InitPtr<T>` owns no concurrency state of its own; whether it is
|
||||
// Send/Sync depends on `T`. The bounds below mirror `Box<T>`.
|
||||
unsafe impl<T: Send> Send for InitPtr<T> {}
|
||||
unsafe impl<T: Sync> Sync for InitPtr<T> {}
|
||||
|
||||
impl<T> InitPtr<T> {
|
||||
/// Wrap a raw pointer after checking alignment and non-null. The
|
||||
/// initialization invariant is not statically checkable here; callers must
|
||||
/// only feed pointers to memory that was written before this call.
|
||||
pub fn new(ptr: *mut T) -> Option<Self> {
|
||||
if !ptr.is_aligned() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: alignment checked above; `NonNull::new` filters null. The
|
||||
// caller is documented to provide an initialized location.
|
||||
NonNull::new(ptr).map(|inner| Self { inner, _marker: PhantomData })
|
||||
}
|
||||
|
||||
/// Wrap a raw pointer the caller asserts is valid.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `ptr` is non-null.
|
||||
/// - `ptr` is aligned to `align_of::<T>()`.
|
||||
/// - `*ptr` is initialized at the time of this call.
|
||||
/// - For the lifetime of the returned value, no other `&T` or `&mut T`
|
||||
/// aliases `*ptr`.
|
||||
pub unsafe fn new_unchecked(ptr: *mut T) -> Self {
|
||||
// SAFETY: caller upholds non-null per the function contract.
|
||||
Self { inner: unsafe { NonNull::new_unchecked(ptr) }, _marker: PhantomData }
|
||||
}
|
||||
|
||||
pub fn as_ref(&self) -> &T {
|
||||
// SAFETY: the constructor's invariants guarantee `inner` points at an
|
||||
// initialized, aligned, non-aliased `T`. Reborrowing through `&self`
|
||||
// ties the resulting lifetime to `self`, enforcing the rest via
|
||||
// standard borrow rules.
|
||||
unsafe { self.inner.as_ref() }
|
||||
}
|
||||
|
||||
pub fn as_mut(&mut self) -> &mut T {
|
||||
// SAFETY: `&mut self` proves no other reference can alias `inner` for
|
||||
// the lifetime of the returned `&mut T`; remaining invariants come
|
||||
// from construction.
|
||||
unsafe { self.inner.as_mut() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The list of features this single shape gives you:
|
||||
|
||||
- The agent cannot construct `InitPtr<T>` without going through a checked path or accepting the `unsafe` obligation explicitly.
|
||||
- The agent cannot leak the raw pointer; `as_ref` / `as_mut` return safe references with proper lifetimes.
|
||||
- The agent cannot accidentally Send/Sync where it shouldn't - the `unsafe impl` is explicit per-bound.
|
||||
- `#[repr(transparent)]` means the type is layout-compatible with `*mut T` for FFI, without exposing the raw pointer.
|
||||
|
||||
## SAFETY Comment Grammar
|
||||
|
||||
Every comment maps one-to-one to an invariant. Format:
|
||||
|
||||
```rust
|
||||
// SAFETY: <which invariant is upheld>: <how it is established here>.
|
||||
```
|
||||
|
||||
Anti-examples (do not pass review):
|
||||
|
||||
```rust
|
||||
// SAFETY: this is fine
|
||||
// SAFETY: we know what we're doing
|
||||
// SAFETY: the caller will not pass null
|
||||
// SAFETY: tested
|
||||
```
|
||||
|
||||
Good examples:
|
||||
|
||||
```rust
|
||||
// SAFETY: `len <= self.capacity` was checked at line 87 and `self.ptr` was
|
||||
// allocated by the same allocator we are reading through.
|
||||
|
||||
// SAFETY: `read_volatile` requires alignment and non-null; both hold because
|
||||
// `self.inner` is an `InitPtr<T>` whose constructor enforced them.
|
||||
|
||||
// SAFETY: We hold `&mut self`, so no concurrent reader exists. The slice
|
||||
// reference is dropped before the next `&self` borrow because we shrink the
|
||||
// returned scope manually.
|
||||
```
|
||||
|
||||
## Persistent Memory Arena Pattern (the Chris Allen example)
|
||||
|
||||
A persistent memory arena (PMA) is an `mmap`-backed bump allocator that survives process restarts. It is the classic "lots of unsafe under one safe surface" project.
|
||||
|
||||
Shape:
|
||||
|
||||
```rust
|
||||
pub struct Arena {
|
||||
map: Mmap, // wraps `mmap(2)` - safe wrapper from `memmap2` crate
|
||||
head: AtomicUsize, // current bump offset, atomic for multi-writer if needed
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
pub fn open(path: &Path, capacity: usize) -> std::io::Result<Self> { /* mmap, init header */ }
|
||||
|
||||
pub fn alloc<T>(&self, value: T) -> Result<Handle<T>, ArenaFull> {
|
||||
let layout = Layout::new::<T>();
|
||||
let aligned = align_up(self.head.load(Acquire), layout.align());
|
||||
let next = aligned.checked_add(layout.size()).ok_or(ArenaFull)?;
|
||||
if next > self.map.len() { return Err(ArenaFull); }
|
||||
// CAS the head forward; retry on contention.
|
||||
// ... omitted for brevity ...
|
||||
|
||||
// SAFETY: `aligned + layout.size() <= self.map.len()` was just proven.
|
||||
// The mmap region is exclusively owned by this arena while we hold the
|
||||
// bump. `aligned` is aligned to `layout.align()` by `align_up`. No
|
||||
// other writer can have observed this offset because the CAS above
|
||||
// returned `Ok`.
|
||||
let ptr = unsafe { self.map.as_mut_ptr().add(aligned) as *mut T };
|
||||
// SAFETY: `ptr` is non-null (mmap base + offset), aligned (above),
|
||||
// exclusively owned (CAS), and we are about to initialize it.
|
||||
unsafe { ptr::write(ptr, value) };
|
||||
|
||||
// SAFETY: same invariants; we wrap the now-initialized pointer in the
|
||||
// safe handle which encapsulates further accesses.
|
||||
Ok(unsafe { Handle::new_unchecked(ptr, self) })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Handle<'a, T> {
|
||||
inner: InitPtr<T>,
|
||||
_arena: PhantomData<&'a Arena>,
|
||||
}
|
||||
```
|
||||
|
||||
Three `unsafe` blocks, three SAFETY comments, one safe handle type emerging on the other side. The agent now uses `Handle<T>` everywhere - never `*mut T`.
|
||||
|
||||
## Miri Invocation
|
||||
|
||||
```bash
|
||||
# install once
|
||||
rustup install nightly
|
||||
rustup component add miri rust-src --toolchain nightly
|
||||
|
||||
# run on every change that touches unsafe
|
||||
MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check" \
|
||||
cargo +nightly miri nextest run --all-features
|
||||
```
|
||||
|
||||
What miri catches that the borrow checker cannot:
|
||||
|
||||
- Use-after-free
|
||||
- Double-free
|
||||
- Reads of uninitialized memory
|
||||
- Pointer-from-integer reconstruction that violates strict provenance
|
||||
- Alignment lies (transmuting unaligned data)
|
||||
- Stacked borrows / Tree borrows aliasing violations
|
||||
- Data races (single-threaded model, but catches concurrent access through `UnsafeCell` misuse)
|
||||
- Atomic ordering bugs in some patterns
|
||||
- Memory leaks (with `-Zmiri-track-pointer-tag`)
|
||||
|
||||
## When Miri Cannot Run
|
||||
|
||||
Certain paths are off-limits for miri: most syscalls beyond a curated allowlist, real network I/O, `std::process` calls, OS-specific FFI, hardware-dependent intrinsics on non-x86. Strategy:
|
||||
|
||||
1. **Isolate.** Put the un-mirifiable code in its own module behind `#[cfg(feature = "ffi-real")]` or similar.
|
||||
2. **Mock at the boundary.** For everything below the FFI boundary, write a safe Rust fake (a `Vec<u8>`-backed "disk", a fake clock, an in-memory socket pair). Expose it as a trait the production code consumes.
|
||||
3. **Test the fake under miri.** The fake implementation exercises the same logic minus the syscall. If the logic is unsafe (raw pointer manipulation in the fake "disk" buffer), miri catches the bug.
|
||||
4. **Test the real path under regular `cargo test`.** With `cargo nextest run --features ffi-real`. No miri, but the surface area is now just the syscall boundary.
|
||||
5. **Document.** A `# Safety` section in the rustdoc names the obligations the FFI puts on us, and a `# Testing` section explains the mock-vs-real split.
|
||||
|
||||
## Loom for Concurrency
|
||||
|
||||
When `unsafe` participates in a concurrent algorithm (lock-free queue, hazard pointers, custom Arc), miri's single-thread model is insufficient. Use `loom`:
|
||||
|
||||
```rust
|
||||
#[cfg(loom)]
|
||||
use loom::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[cfg(not(loom))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(loom)]
|
||||
fn concurrent_push_pop() {
|
||||
loom::model(|| {
|
||||
let queue = std::sync::Arc::new(MyQueue::new());
|
||||
let q1 = queue.clone();
|
||||
let q2 = queue.clone();
|
||||
let h1 = loom::thread::spawn(move || q1.push(1));
|
||||
let h2 = loom::thread::spawn(move || q2.pop());
|
||||
h1.join().unwrap();
|
||||
h2.join().unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `RUSTFLAGS="--cfg loom" cargo test --release`. Loom exhaustively explores thread interleavings for the test scope. Combined with miri on the single-thread paths, you have machine-checked soundness over the full state space.
|
||||
|
||||
## The Forbidden List
|
||||
|
||||
Reject in code review, automatic CI fail:
|
||||
|
||||
- `unsafe { ... }` with no SAFETY comment within 5 lines above.
|
||||
- `unsafe { unsafe_op_a(); unsafe_op_b(); }` (multiple unsafe ops in one block - split them, one SAFETY each). Clippy: `multiple_unsafe_ops_per_block`.
|
||||
- `unsafe fn` exposed publicly without a documented `# Safety` section in rustdoc.
|
||||
- `std::mem::transmute` for anything but lifetime extension on the same layout (and that should usually be `core::mem::transmute_copy` or `bytemuck::cast` if the relayout is well-defined).
|
||||
- `std::ptr::read_unaligned` / `write_unaligned` without a comment explaining why aligned access is impossible.
|
||||
- `from_raw_parts` / `from_raw_parts_mut` without proving the source pointer's provenance covers the entire slice.
|
||||
- `Arc::get_mut_unchecked`, `Box::leak` to bypass ownership, `MaybeUninit::assume_init` on partially-initialized data.
|
||||
- `unsafe impl Send`, `unsafe impl Sync` on types containing raw pointers, without a comment naming exactly which interior-mutability rule is upheld.
|
||||
- Any `unsafe` block whose justification depends on "in practice this never happens".
|
||||
|
||||
## The One-Line Summary
|
||||
|
||||
> Wrap once. Prove once. Test under miri. Never let `unsafe` escape.
|
||||
@@ -0,0 +1,527 @@
|
||||
# Zero-Cost Safety — Zig Ergonomics in Rust
|
||||
|
||||
Rust already owns memory safety. This reference adds the patterns that give you Zig's *ergonomic* safety — explicit allocation control, compile-time computation, zero-hidden-cost APIs, bit-level layout, and deterministic cleanup — without leaving the Rust toolchain.
|
||||
|
||||
**When to load this file:** arena, allocator, bumpalo, const fn, const generics, comptime, zero-alloc, no-alloc, slice-based API, `#[repr]`, packed struct, bitfield, scopeguard, errdefer, RAII cleanup, Zig-like patterns.
|
||||
|
||||
---
|
||||
|
||||
## 1. Explicit Allocators — Arena Pattern
|
||||
|
||||
Zig passes `allocator: Allocator` to every function. Rust's stable equivalent: arena crates that make allocation scope visible and bulk-freeable.
|
||||
|
||||
### bumpalo — The Default Arena
|
||||
|
||||
```rust
|
||||
use bumpalo::Bump;
|
||||
|
||||
fn parse_tokens<'a>(arena: &'a Bump, input: &[u8]) -> Vec<&'a str> {
|
||||
// All allocations go into `arena`. Caller controls lifetime.
|
||||
// When `arena` drops, everything frees in one shot.
|
||||
let token = arena.alloc_str("hello");
|
||||
let slice = arena.alloc_slice_copy(&[1u8, 2, 3]);
|
||||
vec![token] // Vec itself is on heap; contents point into arena
|
||||
}
|
||||
|
||||
// Usage: caller owns the arena, decides when memory dies.
|
||||
let arena = Bump::new();
|
||||
let tokens = parse_tokens(&arena, b"...");
|
||||
drop(arena); // all arena memory freed, zero individual deallocations
|
||||
```
|
||||
|
||||
**When to use:** parsers, compilers, game frame allocators, request-scoped web handlers, any hot loop where individual `Box`/`Vec` alloc+free overhead matters.
|
||||
|
||||
### typed-arena — Homogeneous Arena
|
||||
|
||||
```rust
|
||||
use typed_arena::Arena;
|
||||
|
||||
struct AstNode { kind: u8, children: Vec<&'static AstNode> } // simplified
|
||||
|
||||
let node_arena: Arena<AstNode> = Arena::new();
|
||||
let root = node_arena.alloc(AstNode { kind: 0, children: vec![] });
|
||||
// All nodes share arena lifetime. No individual free.
|
||||
```
|
||||
|
||||
**When to use:** tree/graph structures where all nodes have the same type and same lifetime.
|
||||
|
||||
### allocator_api (nightly) — Full Zig Parity
|
||||
|
||||
```rust
|
||||
#![feature(allocator_api)]
|
||||
use std::alloc::Global;
|
||||
|
||||
// Vec parameterized by allocator — exactly like Zig.
|
||||
let v: Vec<u8, &Bump> = Vec::new_in(&arena);
|
||||
|
||||
// Custom allocator for tracking, limiting, or redirecting allocation
|
||||
struct CountingAlloc { inner: Global, count: AtomicUsize }
|
||||
unsafe impl Allocator for CountingAlloc { /* ... */ }
|
||||
```
|
||||
|
||||
**When to use:** when you need allocator-generic data structures on nightly. For stable code, prefer `bumpalo` directly.
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
Need arena allocation?
|
||||
├── All items same type, same lifetime → typed-arena
|
||||
├── Mixed types, same lifetime → bumpalo
|
||||
├── Need allocator-generic containers → allocator_api (nightly)
|
||||
└── Just need fewer allocations → SmallVec / ArrayVec / tinyvec (stack-first)
|
||||
```
|
||||
|
||||
### Cargo.toml
|
||||
|
||||
```toml
|
||||
bumpalo = { version = "3", features = ["collections"] }
|
||||
typed-arena = "2"
|
||||
smallvec = { version = "1", features = ["union", "const_generics"] }
|
||||
tinyvec = { version = "1", features = ["alloc"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Compile-Time Computation — const fn, const generics, proc macros
|
||||
|
||||
Zig's `comptime` runs arbitrary code at compile time. Rust splits this across three mechanisms.
|
||||
|
||||
### const fn — Compile-Time Pure Functions
|
||||
|
||||
```rust
|
||||
const fn fibonacci(n: usize) -> usize {
|
||||
match n {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => fibonacci(n - 1) + fibonacci(n - 2),
|
||||
}
|
||||
}
|
||||
|
||||
const FIB_20: usize = fibonacci(20); // computed at compile time: 6765
|
||||
|
||||
// Use in array sizes
|
||||
const LOOKUP: [u8; 256] = {
|
||||
let mut table = [0u8; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
table[i] = (i as u8).wrapping_mul(7);
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
```
|
||||
|
||||
**Stable since Rust 1.82:** `const fn` supports `match`, loops, `if`, references, mutable locals — nearly full Rust. Use `const { }` blocks (Rust 1.79+) for inline compile-time assertions.
|
||||
|
||||
```rust
|
||||
fn process<const N: usize>(data: &[u8; N]) {
|
||||
const { assert!(N > 0, "N must be positive") }; // compile-time panic if N == 0
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### const generics — Type-Level Values
|
||||
|
||||
```rust
|
||||
struct Buffer<const N: usize> {
|
||||
data: [u8; N],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<const N: usize> Buffer<N> {
|
||||
const fn new() -> Self {
|
||||
Self { data: [0; N], len: 0 }
|
||||
}
|
||||
|
||||
fn push(&mut self, byte: u8) -> Result<(), BufferFullError> {
|
||||
if self.len >= N { return Err(BufferFullError); }
|
||||
self.data[self.len] = byte;
|
||||
self.len += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Compiler enforces: Buffer<16> and Buffer<32> are distinct types.
|
||||
let small: Buffer<16> = Buffer::new();
|
||||
let large: Buffer<1024> = Buffer::new();
|
||||
```
|
||||
|
||||
### proc macros — Code Generation (Zig comptime type creation)
|
||||
|
||||
When `const fn` is not enough (generating struct fields, impl blocks, or derive logic), proc macros fill the gap.
|
||||
|
||||
```rust
|
||||
// In a proc-macro crate:
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, DeriveInput};
|
||||
|
||||
#[proc_macro_derive(Builder)]
|
||||
pub fn derive_builder(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let name = &input.ident;
|
||||
// ... generate builder struct and impl
|
||||
TokenStream::from(quote! {
|
||||
impl #name {
|
||||
pub fn builder() -> #name##Builder { /* ... */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Decision tree:**
|
||||
|
||||
```
|
||||
Need compile-time value computation? → const fn
|
||||
Need type parameterized by value? → const generics
|
||||
Need to generate new types/impls? → proc macro (derive or attribute)
|
||||
Need compile-time string processing? → proc macro
|
||||
Need typenum-level arithmetic? → typenum / generic-array (rare)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Zero-Allocation API Design — No Hidden Costs
|
||||
|
||||
Zig's philosophy: no operator overloading, no hidden allocation, every cost visible. Rust achieves this with discipline.
|
||||
|
||||
### Slice-Based APIs — Caller Owns Memory
|
||||
|
||||
```rust
|
||||
// BAD: hidden allocation in return type
|
||||
fn process(input: &str) -> String {
|
||||
input.to_uppercase() // allocates
|
||||
}
|
||||
|
||||
// GOOD: caller provides output buffer, zero allocation
|
||||
fn process(input: &[u8], output: &mut [u8]) -> usize {
|
||||
let len = input.len().min(output.len());
|
||||
for i in 0..len {
|
||||
output[i] = input[i].to_ascii_uppercase();
|
||||
}
|
||||
len // returns bytes written
|
||||
}
|
||||
|
||||
// GOOD: return borrowed data when possible
|
||||
fn find_token<'a>(input: &'a str) -> Option<&'a str> {
|
||||
input.split_whitespace().next() // no allocation — borrows from input
|
||||
}
|
||||
```
|
||||
|
||||
### try_* APIs — Fallible Allocation
|
||||
|
||||
```rust
|
||||
// Allocation can fail explicitly (like Zig's allocator returning error)
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve(1_000_000)?; // returns Result, not panic
|
||||
|
||||
// For Box:
|
||||
let b = Box::try_new(42)?; // nightly, or use allocator_api
|
||||
```
|
||||
|
||||
### SmallVec / ArrayVec — Stack-First Collections
|
||||
|
||||
```rust
|
||||
use smallvec::SmallVec;
|
||||
use arrayvec::ArrayVec;
|
||||
|
||||
// SmallVec: stack for small counts, heap spillover for large
|
||||
let mut tags: SmallVec<[u8; 8]> = SmallVec::new();
|
||||
tags.push(1); // on stack if <= 8 elements
|
||||
|
||||
// ArrayVec: purely stack, fixed capacity, no heap ever
|
||||
let mut buf: ArrayVec<u8, 64> = ArrayVec::new();
|
||||
buf.try_push(42).map_err(|_| "full")?; // returns error instead of panic
|
||||
```
|
||||
|
||||
### Cow — Defer Allocation Until Mutation
|
||||
|
||||
```rust
|
||||
use std::borrow::Cow;
|
||||
|
||||
fn normalize(input: &str) -> Cow<'_, str> {
|
||||
if input.contains('\t') {
|
||||
Cow::Owned(input.replace('\t', " ")) // allocates only when needed
|
||||
} else {
|
||||
Cow::Borrowed(input) // zero-cost pass-through
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The #![no_std] Discipline
|
||||
|
||||
For maximum allocation control, go `#![no_std]`:
|
||||
|
||||
```rust
|
||||
#![no_std]
|
||||
extern crate alloc; // opt-in to heap when needed
|
||||
|
||||
use alloc::vec::Vec; // explicit: I chose to allocate
|
||||
use alloc::string::String; // explicit: I chose to allocate
|
||||
```
|
||||
|
||||
Even in `std` code, the *mindset* applies: prefer `&[T]` over `Vec<T>` in function signatures, `&str` over `String`, `&Path` over `PathBuf`.
|
||||
|
||||
### Clippy Lints for Hidden Allocations
|
||||
|
||||
```toml
|
||||
# Cargo.toml — catch accidental allocations
|
||||
[lints.clippy]
|
||||
# These warn on patterns that allocate when a borrow would suffice:
|
||||
unnecessary_to_owned = "warn" # .to_string() / .to_vec() when borrow works
|
||||
redundant_clone = "warn" # .clone() that's immediately consumed
|
||||
large_stack_arrays = "warn" # accidental large stack usage
|
||||
vec_init_then_push = "warn" # Vec::new() + push instead of vec![]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Bit-Level Layout — repr, Packed Structs, Bitfields
|
||||
|
||||
Zig: `packed struct` with bit-level field control. Rust matches with `#[repr]` attributes and bitfield crates.
|
||||
|
||||
### #[repr(C)] — Guaranteed C-Compatible Layout
|
||||
|
||||
```rust
|
||||
#[repr(C)]
|
||||
struct Header {
|
||||
magic: [u8; 4],
|
||||
version: u16,
|
||||
flags: u16,
|
||||
length: u32,
|
||||
}
|
||||
// Layout is C ABI: fields in declaration order, C padding rules.
|
||||
// Safe to transmute from/to byte arrays via zerocopy.
|
||||
```
|
||||
|
||||
### #[repr(C, packed)] — No Padding
|
||||
|
||||
```rust
|
||||
#[repr(C, packed)]
|
||||
struct WireHeader {
|
||||
tag: u8,
|
||||
length: u16, // NOT aligned to 2-byte boundary
|
||||
checksum: u32,
|
||||
}
|
||||
// Total size: exactly 7 bytes. No padding.
|
||||
// WARNING: taking &self.length is UB if unaligned. Use read_unaligned or zerocopy.
|
||||
```
|
||||
|
||||
**Safe access pattern:**
|
||||
|
||||
```rust
|
||||
use std::ptr;
|
||||
|
||||
impl WireHeader {
|
||||
fn length(&self) -> u16 {
|
||||
// SAFETY: packed field may be unaligned; ptr::read_unaligned handles this.
|
||||
unsafe { ptr::read_unaligned(ptr::addr_of!(self.length)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Better: use zerocopy to avoid manual unsafe entirely
|
||||
use zerocopy::{FromBytes, IntoBytes, KnownLayout, Immutable};
|
||||
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable)]
|
||||
#[repr(C, packed)]
|
||||
struct WireHeader {
|
||||
tag: u8,
|
||||
length: [u8; 2], // manual byte array avoids alignment issues
|
||||
checksum: [u8; 4],
|
||||
}
|
||||
|
||||
impl WireHeader {
|
||||
fn length(&self) -> u16 { u16::from_le_bytes(self.length) }
|
||||
fn checksum(&self) -> u32 { u32::from_le_bytes(self.checksum) }
|
||||
}
|
||||
```
|
||||
|
||||
### bitfield — Bit-Level Flag Packing
|
||||
|
||||
```rust
|
||||
use bitfield::bitfield;
|
||||
|
||||
bitfield! {
|
||||
pub struct Permissions(u8);
|
||||
impl Debug;
|
||||
pub bool, readable, set_readable: 0;
|
||||
pub bool, writable, set_writable: 1;
|
||||
pub bool, executable, set_executable: 2;
|
||||
pub u8, level, set_level: 5, 3; // bits 3-5
|
||||
}
|
||||
|
||||
let mut p = Permissions(0);
|
||||
p.set_readable(true);
|
||||
p.set_level(5);
|
||||
assert!(p.readable());
|
||||
assert_eq!(p.level(), 5);
|
||||
```
|
||||
|
||||
### modular-bitfield — Richer Bitfield API
|
||||
|
||||
```rust
|
||||
use modular_bitfield::prelude::*;
|
||||
|
||||
#[bitfield(bits = 16)]
|
||||
#[derive(Debug)]
|
||||
pub struct StatusWord {
|
||||
ready: bool, // 1 bit
|
||||
error_code: B4, // 4 bits
|
||||
#[skip] __: B3, // 3 bits padding
|
||||
priority: B8, // 8 bits
|
||||
}
|
||||
```
|
||||
|
||||
### zerocopy — Safe Zero-Copy Parsing
|
||||
|
||||
```rust
|
||||
use zerocopy::{FromBytes, IntoBytes, KnownLayout, Immutable, Ref};
|
||||
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable)]
|
||||
#[repr(C)]
|
||||
struct Packet {
|
||||
header: [u8; 4],
|
||||
payload_len: u32,
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<&Packet> {
|
||||
Ref::<_, Packet>::from_prefix(bytes).map(|(pkt, _rest)| pkt.into_ref()).ok()
|
||||
}
|
||||
// Zero-copy, zero-allocation, fully safe. No transmute, no pointer cast.
|
||||
```
|
||||
|
||||
### Cargo.toml
|
||||
|
||||
```toml
|
||||
zerocopy = { version = "0.8", features = ["derive"] }
|
||||
bitfield = "0.17"
|
||||
modular-bitfield = "0.11"
|
||||
bytemuck = { version = "1", features = ["derive"] } # alternative to zerocopy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Scope Guards — errdefer / Deterministic Cleanup
|
||||
|
||||
Zig's `errdefer` runs cleanup only on error paths. Rust's `Drop` always runs, but `scopeguard` gives fine-grained control.
|
||||
|
||||
### scopeguard — The errdefer Equivalent
|
||||
|
||||
```rust
|
||||
use scopeguard::{defer, guard};
|
||||
use std::fs;
|
||||
|
||||
fn create_and_process(path: &str) -> std::io::Result<()> {
|
||||
let file = fs::File::create(path)?;
|
||||
// If anything below fails, clean up the file.
|
||||
// This is exactly Zig's errdefer.
|
||||
let _cleanup = guard((), |_| {
|
||||
let _ = fs::remove_file(path);
|
||||
});
|
||||
|
||||
write_data(&file)?;
|
||||
validate_data(path)?;
|
||||
|
||||
// Success: defuse the guard so it does NOT run cleanup.
|
||||
std::mem::forget(_cleanup);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### defer! — Always-Run Cleanup (like Zig's defer)
|
||||
|
||||
```rust
|
||||
use scopeguard::defer;
|
||||
|
||||
fn with_temp_dir() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
defer! {
|
||||
// Runs when scope exits, success or failure.
|
||||
println!("Cleaning up {}", dir.path().display());
|
||||
// dir's Drop also cleans up, but this shows the pattern.
|
||||
}
|
||||
|
||||
do_work(dir.path())?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Drop as RAII Cleanup
|
||||
|
||||
```rust
|
||||
struct TempFile { path: std::path::PathBuf }
|
||||
|
||||
impl TempFile {
|
||||
fn new(path: impl Into<std::path::PathBuf>) -> std::io::Result<Self> {
|
||||
let path = path.into();
|
||||
std::fs::File::create(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: file is auto-cleaned when `tmp` goes out of scope.
|
||||
let tmp = TempFile::new("/tmp/scratch.dat")?;
|
||||
```
|
||||
|
||||
### The errdefer Pattern — Defuse on Success
|
||||
|
||||
The key insight from Zig's `errdefer`: you want cleanup on error but NOT on success. In Rust:
|
||||
|
||||
```rust
|
||||
use scopeguard::ScopeGuard;
|
||||
|
||||
fn deploy(artifact: &Path) -> Result<(), DeployError> {
|
||||
let backup = backup_current()?;
|
||||
|
||||
// errdefer: restore backup if anything fails
|
||||
let rollback = guard(backup.clone(), |b| {
|
||||
let _ = restore_from_backup(&b);
|
||||
});
|
||||
|
||||
upload(artifact)?;
|
||||
health_check()?;
|
||||
|
||||
// Success path: defuse the rollback guard
|
||||
ScopeGuard::into_inner(rollback);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Cargo.toml
|
||||
|
||||
```toml
|
||||
scopeguard = "1"
|
||||
tempfile = "3" # idiomatic RAII temp files/dirs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: Zig Advantage → Rust Pattern
|
||||
|
||||
| Zig Feature | Rust Equivalent | Difficulty | Reference |
|
||||
|---|---|---|---|
|
||||
| Explicit allocator passing | `bumpalo` / `typed-arena` / `allocator_api` | Easy | §1 |
|
||||
| `comptime` value computation | `const fn` + `const { }` blocks | Easy | §2 |
|
||||
| `comptime` type generation | proc macros (derive / attribute) | Medium | §2 |
|
||||
| No hidden allocations | `#![no_std]` / slice-based APIs / `Cow` | Style choice | §3 |
|
||||
| `packed struct` / bitfields | `#[repr(C, packed)]` / `bitfield` / `zerocopy` | Easy | §4 |
|
||||
| `errdefer` | `scopeguard::guard` + defuse on success | Easy | §5 |
|
||||
| `defer` | `scopeguard::defer!` / `Drop` | Easy | §5 |
|
||||
|
||||
All achievable within Rust's single toolchain. You get Zig's explicitness **plus** the borrow checker, lifetime analysis, trait bounds, and `miri`. The combination is strictly more powerful than either alone.
|
||||
|
||||
## When NOT to Use These Patterns
|
||||
|
||||
- **Arena allocation** overkill for simple CLI tools that allocate once and exit.
|
||||
- **Zero-alloc APIs** hurt readability when the function naturally produces owned data. Don't force `&mut [u8]` output buffers on a function that logically returns `String`.
|
||||
- **`#[repr(packed)]`** only for wire formats and FFI. Never for regular domain types.
|
||||
- **Scope guards** unnecessary when `Drop` on the value itself handles cleanup (e.g., `tempfile::NamedTempFile` already does this).
|
||||
- **`const fn`** everything? No — only when the value is genuinely needed at compile time or the function is trivially const-eligible. Don't contort logic just to be const.
|
||||
|
||||
The goal is **visible costs and explicit control**, not asceticism. Use `String` and `Vec` freely when they're the right tool. Reach for these patterns when allocation behavior matters for correctness or performance.
|
||||
@@ -0,0 +1,195 @@
|
||||
|
||||
# TypeScript Programmer
|
||||
|
||||
Modern TypeScript. Type-strict, stack-first, async-correct.
|
||||
|
||||
## Philosophy
|
||||
|
||||
The compiler is your proof system. Make illegal states unrepresentable. Parse at boundaries. Every function has a contract; the type system enforces it.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are deliberate project choices. Violations are always wrong, not "style preferences".
|
||||
|
||||
### Tooling
|
||||
|
||||
| Category | Use | Never |
|
||||
|---|---|---|
|
||||
| Runtime | Bun (native TS, single binary) | ts-node, tsx |
|
||||
| Package manager | `pnpm` | npm, yarn (unless workspace requires it) |
|
||||
| Linter + formatter | Biome | ESLint, Prettier |
|
||||
| Type checker | `tsc --noEmit` with strict config | skip type checking |
|
||||
| Web framework | Hono | Express |
|
||||
| Validation | Zod | joi, yup, class-validator |
|
||||
| Testing | `bun test` or vitest | jest |
|
||||
| ORM | Drizzle | TypeORM, Prisma (unless already in project) |
|
||||
|
||||
### The iron list
|
||||
|
||||
1. **Readonly by default** — all `type`/`interface` properties are `readonly`. Arrays are `readonly T[]`. Mutable only when mutation is the documented purpose.
|
||||
2. **Branded types for distinct IDs** — `type UserId = Brand<string, "UserId">`. Never pass raw `string` where a branded type exists.
|
||||
3. **Exhaustive switch** — every `switch` on a discriminated union ends with `default: assertNever(x)`. No fall-through.
|
||||
4. **No any** — `any` is banned in annotations, returns, and parameters. Use `unknown` and narrow.
|
||||
5. **No type assertions** — `as any`, `as unknown` banned. `as const` and `satisfies` are fine.
|
||||
6. **No non-null assertion** — `x!` is banned. Use narrowing or optional chaining (`x?.y`).
|
||||
7. **No @ts-ignore / @ts-expect-error** — fix the type.
|
||||
8. **No enum** — use `as const` objects + literal union types.
|
||||
9. **Zod at boundaries** — external input (API, user, file) → Zod schema. Internal → plain types.
|
||||
10. **Typed errors** — Error subclasses with typed fields. No `throw new Error("bare string")` for domain errors. Use Result for expected failures within 1-2 call levels; throw for propagation across many layers.
|
||||
11. **as const for constants** — module-level constant objects and arrays use `as const`.
|
||||
12. **import type** — type-only imports use `import type`. Enforced by `verbatimModuleSyntax`.
|
||||
13. **Named exports only** — no `export default`. Exception: framework requirement (Next.js pages, etc.).
|
||||
14. **No empty catch, no catch-and-swallow** — every `catch` block must either (a) narrow the error with `instanceof` and handle each case, or (b) re-throw. Empty catch blocks and `catch (e) { console.error(e) }` without narrowing or re-throw are banned — they hide bugs. At top-level boundaries (CLI entry, HTTP handler), opt out with `// no-excuse-ok: catch`.
|
||||
|
||||
### Data modeling — which construct, when
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| User input, API request/response | Zod schema + `z.infer` |
|
||||
| Internal value object | `type` with `readonly` properties |
|
||||
| Function with multiple outcomes | Discriminated union (`kind` field) |
|
||||
| Contract for implementations | `interface` |
|
||||
| Fixed constants | `as const` + literal union |
|
||||
| Distinct primitive (UserId vs OrderId) | Branded type |
|
||||
| Key-value map | `Record<K, V>` or index signature |
|
||||
|
||||
**The one rule**: data crosses trust boundary → Zod. Everything else → plain `type` with `readonly`.
|
||||
|
||||
Load `data-modeling.md` for the full decision flowchart and comparison.
|
||||
|
||||
### When readonly does not apply
|
||||
|
||||
- **Framework state** (React `useState`, signals) — managed by framework.
|
||||
- **Builder / accumulator** — object exists to be mutated (buffer, cache). Document why.
|
||||
- **ORM mutations** — Drizzle insert/update objects.
|
||||
|
||||
### Why empty/unhandled catch is banned
|
||||
|
||||
In TypeScript, every `catch` receives `unknown`. The language gives you no type safety in catch blocks — you must earn it with `instanceof`. A bare `catch (e) { console.error(e) }` swallows `TypeError`, `RangeError`, and your domain errors identically. When a new error type appears, nothing warns you.
|
||||
|
||||
```typescript
|
||||
// BANNED — empty catch
|
||||
try { await fetchData() } catch {}
|
||||
try { await fetchData() } catch (e) { /* will fix later */ }
|
||||
|
||||
// BANNED — catch-and-swallow (no narrowing, no rethrow)
|
||||
try {
|
||||
const data = await api.get("/users")
|
||||
} catch (e) {
|
||||
console.error("failed", e)
|
||||
}
|
||||
|
||||
// GOOD — narrow with instanceof
|
||||
try {
|
||||
const data = await api.get("/users")
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) {
|
||||
logger.warn(`API ${e.status}: ${e.message}`)
|
||||
return fallback
|
||||
}
|
||||
throw e // unknown errors propagate
|
||||
}
|
||||
|
||||
// GOOD — top-level boundary (only place catch-all is acceptable)
|
||||
async function main(): Promise<void> { // no-excuse-ok: catch
|
||||
try {
|
||||
await run()
|
||||
} catch (e) {
|
||||
console.error("unhandled:", e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Libraries
|
||||
|
||||
| Domain | Library | Why |
|
||||
|---|---|---|
|
||||
| HTTP framework | Hono | Lightweight, multi-runtime, middleware, OpenAPI |
|
||||
| Validation | Zod | Runtime validation + type inference |
|
||||
| ORM | Drizzle | Type-safe SQL, no codegen |
|
||||
| HTTP client | `ky` | Thin fetch wrapper (5KB); auto-throw on non-2xx, retry, timeout, hooks, prefixUrl. Browser + Node + Bun + Deno |
|
||||
| HTTP client (perf) | `undici` (direct API) | Node backend에서 connection pooling, HTTP/2, pipelining 필요 시 |
|
||||
|
||||
> **HTTP client 규칙** — 프로덕션 코드에서 bare `fetch()`는 사용 금지. retry·timeout·에러 핸들링이 전무하여 장애 시 silent failure를 유발한다. **`ky`** 를 기본으로 설치하고, Node backend에서 대량 요청·커넥션 풀·HTTP/2 파이프라이닝이 필요하면 **`undici`** direct API를 쓴다. ~~`axios`~~ 는 supply-chain compromise(2026-03) 이후 사용 금지. `node-fetch`는 Node 18+ 내장 fetch로 대체되어 불필요.
|
||||
| Testing | `bun test` / vitest | Fast, ESM-native |
|
||||
| Logging | `pino` | Structured JSON, fast |
|
||||
| CLI | `@clack/prompts` + `commander` | Interactive + parsing |
|
||||
|
||||
## tsconfig — the one true config
|
||||
|
||||
Scaffold a new project with all strict defaults pre-configured:
|
||||
|
||||
```bash
|
||||
bun run ../../scripts/typescript/new-project.ts my-api
|
||||
bun run ../../scripts/typescript/new-project.ts my-api --path ./projects
|
||||
```
|
||||
|
||||
Creates: `package.json` (Hono + Zod + Biome), `tsconfig.json` (ultra-strict), `biome.json`, `src/index.ts`, `.gitignore`. Works on macOS, Linux, Windows.
|
||||
|
||||
For manual setup: `bunx tsc --init`, then load `tsconfig-strict.md` for the full strict config.
|
||||
|
||||
Key flags beyond `"strict": true`:
|
||||
|
||||
| Flag | What it catches |
|
||||
|---|---|
|
||||
| `noUncheckedIndexedAccess` | `arr[0]` is `T \| undefined`, forces check |
|
||||
| `exactOptionalPropertyTypes` | `{ x?: string }` ≠ `{ x: string \| undefined }` |
|
||||
| `verbatimModuleSyntax` | Forces `import type` for type-only imports |
|
||||
| `noFallthroughCasesInSwitch` | Forgotten `break` / `return` |
|
||||
| `noPropertyAccessFromIndexSignature` | `.key` on index sig → bracket notation |
|
||||
|
||||
## Reference loading
|
||||
|
||||
Load on demand — not all at once.
|
||||
|
||||
| Need | Load |
|
||||
|---|---|
|
||||
| Strict tsconfig + Biome config | `tsconfig-strict.md` |
|
||||
| Type patterns (branded, as const, satisfies, narrowing, assertNever) | `type-patterns.md` |
|
||||
| Data modeling (type vs interface vs Zod, readonly, parse-don't-validate) | `data-modeling.md` |
|
||||
| Error handling (Result, typed errors, union vs throw) | `error-handling.md` |
|
||||
| Bootstrapping a new project (Bun, pnpm, Hono, Vite) | `bootstrap.md` |
|
||||
| Hono backend stack (hono-openapi, Scalar, Swagger) | `backend-hono.md` |
|
||||
|
||||
## No-excuse audit
|
||||
|
||||
Violations caught by `../../scripts/typescript/check-no-excuse-rules.ts`. Run after every edit session.
|
||||
|
||||
| Rule ID | Catches | Opt-out |
|
||||
|---|---|---|
|
||||
| `no-any-assertion` | `as any` | None — redesign types |
|
||||
| `no-unknown-assertion` | `as unknown` | None — redesign types |
|
||||
| `no-ts-ignore` | `@ts-ignore` | None — fix the type |
|
||||
| `no-ts-expect-error` | `@ts-expect-error` | None — fix the type |
|
||||
| `no-enum` | `enum` declarations | None — use `as const` |
|
||||
| `no-non-null-assertion` | `x!` postfix | None — narrow or `?.` |
|
||||
| `no-throw-literal` | `throw "string"` / `throw 123` | None — throw Error subclass |
|
||||
| `no-mutable-export` | `export let` / `export var` | None — use `export const` |
|
||||
| `no-any-annotation` | `: any` in parameter/return/variable types | `// no-excuse-ok: any` |
|
||||
| `no-explicit-any-return` | `(): any` or `(): Promise<any>` return types | `// no-excuse-ok: any` |
|
||||
| `empty-catch` | `catch { }` or `catch (e) { }` with empty body | `// no-excuse-ok: catch` |
|
||||
| `catch-without-narrowing` | `catch (e)` used without `instanceof` or re-throw | `// no-excuse-ok: catch` |
|
||||
|
||||
Biome enforces additional rules (noExplicitAny, noNonNullAssertion, noDefaultExport, useImportType). The script catches what Biome cannot.
|
||||
|
||||
## In tests
|
||||
|
||||
Tests are strict too, with these exceptions (configure in `biome.jsonc` overrides):
|
||||
|
||||
| In tests you may | Why |
|
||||
|---|---|
|
||||
| Use `expect()` assertions | That's how testing works |
|
||||
| Use magic numbers | Test data |
|
||||
| Access private members via bracket notation | Testing internals |
|
||||
| Skip readonly on test fixtures | Mutable setup/teardown |
|
||||
|
||||
Tests still follow the iron list — branded types, typed errors, exhaustive switch.
|
||||
|
||||
## Existing codebases
|
||||
|
||||
When editing an existing file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.**
|
||||
|
||||
## Activation
|
||||
|
||||
This skill activates whenever you are writing or modifying any `.ts` or `.tsx` file. Even one-off scripts get the strict treatment.
|
||||
@@ -0,0 +1,672 @@
|
||||
# Hono Backend Stack Reference (2026)
|
||||
|
||||
> **Canonical stack**: `hono` + `hono-openapi` + `@scalar/hono-api-reference` + `@hono/swagger-ui`
|
||||
> **Runtime**: Bun (TypeScript-first)
|
||||
> **Validator**: Zod v4 (Standard Schema compliant, zero extra deps for OpenAPI)
|
||||
|
||||
---
|
||||
|
||||
## 1. Package Versions (Latest Stable)
|
||||
|
||||
| Package | Version | Source |
|
||||
|---------|---------|--------|
|
||||
| `hono` | `^4.12.5` | [peer dep of scalar](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/package.json#L66) |
|
||||
| `hono-openapi` | `^1.3.0` | [npm](https://registry.npmjs.org/hono-openapi) — published Mar 2, 2026 |
|
||||
| `@scalar/hono-api-reference` | `^0.10.11` | [npm](https://www.npmjs.com/package/@scalar/hono-api-reference) — published Apr 28, 2026 |
|
||||
| `@hono/swagger-ui` | `^0.6.1` | [npm](https://www.npmjs.com/package/@hono/swagger-ui) — published Apr 2026 |
|
||||
| `zod` | `^4.4.1` | [npm registry](https://registry.npmjs.org/zod) — latest stable v4 |
|
||||
|
||||
### `package.json` dependency block
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"hono": "^4.12.5",
|
||||
"hono-openapi": "^1.3.0",
|
||||
"@scalar/hono-api-reference": "^0.10.11",
|
||||
"@hono/swagger-ui": "^0.6.1",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Peer dependencies auto-installed by `hono-openapi`**:
|
||||
> - `@hono/standard-validator@^0.2.0`
|
||||
> - `@standard-community/standard-json@^0.3.5`
|
||||
> - `@standard-community/standard-openapi@^0.2.9`
|
||||
> - `openapi-types@^12.1.3`
|
||||
>
|
||||
> [Source: `hono-openapi/package.json` peerDependencies](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/package.json#L50-L65)
|
||||
|
||||
---
|
||||
|
||||
## 2. Complete `app.ts` — Copy-Pasteable
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler, resolver, validator } from 'hono-openapi'
|
||||
import { Scalar } from '@scalar/hono-api-reference'
|
||||
import { swaggerUI } from '@hono/swagger-ui'
|
||||
import { z } from 'zod'
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 1. Schema definitions (Zod v4 — Standard Schema native)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
const QuerySchema = z.object({
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
const ResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
|
||||
const JsonBodySchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number().int().min(0),
|
||||
})
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 2. Hono app with described routes
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// Health check (no validation)
|
||||
app.get('/health', (c) => c.json({ status: 'ok' }))
|
||||
|
||||
// A fully-documented route
|
||||
app.get(
|
||||
'/hello',
|
||||
describeRoute({
|
||||
tags: ['Greetings'],
|
||||
summary: 'Say hello',
|
||||
description: 'Returns a greeting message',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Successful greeting',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('query', QuerySchema),
|
||||
(c) => {
|
||||
const query = c.req.valid('query')
|
||||
return c.json({ message: `Hello ${query.name ?? 'Hono'}!` })
|
||||
},
|
||||
)
|
||||
|
||||
// A POST route with JSON body validation
|
||||
app.post(
|
||||
'/users',
|
||||
describeRoute({
|
||||
tags: ['Users'],
|
||||
summary: 'Create a user',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'User created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', JsonBodySchema),
|
||||
(c) => {
|
||||
const body = c.req.valid('json')
|
||||
return c.json({ message: `Created user ${body.name}` })
|
||||
},
|
||||
)
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 3. OpenAPI spec endpoint
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'Hono API',
|
||||
version: '1.0.0',
|
||||
description: 'Example Hono API with OpenAPI',
|
||||
},
|
||||
servers: [
|
||||
{ url: 'http://localhost:3000', description: 'Local server' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 4. Scalar API Reference UI
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get(
|
||||
'/scalar',
|
||||
Scalar({
|
||||
url: '/openapi.json',
|
||||
theme: 'saturn',
|
||||
pageTitle: 'Hono API Reference',
|
||||
}),
|
||||
)
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 5. Swagger UI (parallel mount)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get(
|
||||
'/swagger',
|
||||
swaggerUI({
|
||||
url: '/openapi.json',
|
||||
title: 'Swagger UI',
|
||||
}),
|
||||
)
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 6. Bun canonical entrypoint
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `hono-openapi` API Reference
|
||||
|
||||
### Import paths
|
||||
|
||||
**There is only one import path.** `hono-openapi` exports everything from its root:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
describeRoute, // middleware to annotate a route with OpenAPI metadata
|
||||
describeResponse, // attach response schemas directly to a handler
|
||||
validator, // validation middleware (wraps @hono/standard-validator)
|
||||
resolver, // wrap a Zod/Valibot/etc schema for OpenAPI responses
|
||||
openAPIRouteHandler, // serve the generated OpenAPI JSON document
|
||||
generateSpecs, // programmatically generate the spec (for build-time caching)
|
||||
} from 'hono-openapi'
|
||||
```
|
||||
|
||||
**Evidence** ([`src/index.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/index.ts#L1-L9)):
|
||||
|
||||
```typescript
|
||||
export { generateSpecs, openAPIRouteHandler } from "./handler.js";
|
||||
export {
|
||||
describeResponse,
|
||||
describeRoute,
|
||||
loadVendor,
|
||||
resolver,
|
||||
validator,
|
||||
} from "./middlewares.js";
|
||||
```
|
||||
|
||||
> **No subpath exports** such as `hono-openapi/zod` or `hono-openapi/valibot`. The package uses Standard Schema and auto-detects the validator vendor.
|
||||
|
||||
### `describeRoute()` middleware
|
||||
|
||||
Attach OpenAPI metadata to any Hono route. Use `resolver()` for response body schemas.
|
||||
|
||||
```typescript
|
||||
app.get(
|
||||
'/path',
|
||||
describeRoute({
|
||||
tags: ['Users'],
|
||||
summary: 'Get user',
|
||||
description: 'Retrieve a single user by ID',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'User found',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(UserSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'User not found',
|
||||
},
|
||||
},
|
||||
}),
|
||||
handler,
|
||||
)
|
||||
```
|
||||
|
||||
**Evidence** ([`src/middlewares.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L244-L254)):
|
||||
|
||||
```typescript
|
||||
export function describeRoute(spec: DescribeRouteOptions): MiddlewareHandler {
|
||||
const middleware: MiddlewareHandler = async (_c, next) => {
|
||||
await next();
|
||||
};
|
||||
return Object.assign(middleware, {
|
||||
[uniqueSymbol]: { spec },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### `validator()` middleware
|
||||
|
||||
Validates `query`, `json`, `param`, or `form` and **automatically** injects the request schema into the OpenAPI document. No manual `request` block in `describeRoute()` is required.
|
||||
|
||||
```typescript
|
||||
validator('query', QuerySchema) // ?name=foo
|
||||
validator('json', JsonBodySchema) // POST body
|
||||
validator('param', ParamSchema) // /users/:id
|
||||
validator('form', FormSchema) // multipart/form-data
|
||||
```
|
||||
|
||||
**Evidence** ([`src/middlewares.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L199-L237)):
|
||||
|
||||
```typescript
|
||||
export function validator<Schema extends StandardSchemaV1, ...>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
hook?: Hook<...>,
|
||||
options?: ResolverReturnType["options"],
|
||||
): MiddlewareHandler<E, P, V> {
|
||||
const middleware = sValidator(target, schema, hook);
|
||||
return Object.assign(middleware, {
|
||||
[uniqueSymbol]: { target, ...resolver(schema, options), options },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### `openAPIRouteHandler()` — serving the spec
|
||||
|
||||
```typescript
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: { title: 'Hono API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
**Evidence** ([`src/handler.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/handler.ts#L42-L59)):
|
||||
|
||||
```typescript
|
||||
export function openAPIRouteHandler<...>(
|
||||
hono: Hono<E, S, P>,
|
||||
options?: Partial<GenerateSpecOptions>,
|
||||
): MiddlewareHandler<E, P, I> {
|
||||
let specs: OpenAPIV3_1.Document;
|
||||
return async (c) => {
|
||||
if (specs) return c.json(specs);
|
||||
specs = await generateSpecs(hono, options, c);
|
||||
return c.json(specs);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
> **Mount path convention**: `/openapi.json` is the most common. Some projects use `/openapi/spec.json` (e.g. [NamesMT/starter-monorepo](https://github.com/NamesMT/starter-monorepo/blob/main/apps/backend/src/openAPI.ts)).
|
||||
|
||||
---
|
||||
|
||||
## 4. `@scalar/hono-api-reference` Setup
|
||||
|
||||
### Import path and package name
|
||||
|
||||
```typescript
|
||||
import { Scalar } from '@scalar/hono-api-reference'
|
||||
```
|
||||
|
||||
> **Deprecated**: `apiReference` is still exported but deprecated in favor of `Scalar` ([PR #5297](https://github.com/scalar/scalar/pull/5297)).
|
||||
|
||||
**Evidence** ([`integrations/hono/src/index.ts`](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/src/index.ts#L1-L9)):
|
||||
|
||||
```typescript
|
||||
import { Scalar } from './scalar'
|
||||
export {
|
||||
Scalar,
|
||||
/**
|
||||
* @deprecated Use `Scalar` instead.
|
||||
*/
|
||||
Scalar as apiReference,
|
||||
}
|
||||
```
|
||||
|
||||
### Mount path convention
|
||||
|
||||
Common choices:
|
||||
- `/scalar` — matches the middleware name
|
||||
- `/docs` — generic documentation endpoint
|
||||
- `/openapi/ui` — nested under the OpenAPI prefix
|
||||
|
||||
### Configuration options
|
||||
|
||||
The Hono middleware accepts the **universal Scalar configuration** plus Hono-specific overrides (`pageTitle`, `cdn`).
|
||||
|
||||
```typescript
|
||||
app.get('/scalar', Scalar({
|
||||
// ── Source (required) ──
|
||||
url: '/openapi.json', // URL to the OpenAPI spec
|
||||
|
||||
// ── Appearance ──
|
||||
theme: 'saturn', // 'alternate' | 'default' | 'moon' | 'purple'
|
||||
// | 'solarized' | 'bluePlanet' | 'deepSpace'
|
||||
// | 'saturn' | 'kepler' | 'elysiajs' | 'fastify'
|
||||
// | 'mars' | 'laserwave' | 'none'
|
||||
pageTitle: 'My API Docs', // HTML <title>
|
||||
customCss: '.sidebar { ... }', // injected <style> block
|
||||
metaData: { title: '...' }, // SEO meta tags (unhead format)
|
||||
favicon: '/favicon.svg',
|
||||
|
||||
// ── Behavior ──
|
||||
layout: 'modern', // 'modern' | 'classic'
|
||||
darkMode: true,
|
||||
forceDarkModeState: 'dark', // 'dark' | 'light'
|
||||
hideDarkModeToggle: false,
|
||||
hideModels: false,
|
||||
hideSearch: false,
|
||||
hideTestRequestButton: false,
|
||||
showOperationId: false,
|
||||
showSidebar: true,
|
||||
|
||||
// ── Proxy / Server ──
|
||||
proxyUrl: 'https://proxy.scalar.com',
|
||||
baseServerURL: 'http://localhost:3000',
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
|
||||
// ── CDN ──
|
||||
cdn: 'https://cdn.jsdelivr.net/npm/@scalar/api-reference',
|
||||
|
||||
// ── Advanced ──
|
||||
authentication: { ... },
|
||||
hiddenClients: ['unirest'],
|
||||
defaultHttpClient: { targetKey: 'js', clientKey: 'fetch' },
|
||||
plugins: [...],
|
||||
pathRouting: { basePath: '/reference' },
|
||||
mcp: { name: 'My MCP', url: '...' },
|
||||
}))
|
||||
```
|
||||
|
||||
**Evidence** — Scalar types define the full schema:
|
||||
- [Base configuration (themes, proxy, etc.)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/base-configuration.ts#L110-L129)
|
||||
- [HTML rendering configuration (`pageTitle`, `cdn`)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/html-rendering-configuration.ts#L8-L23)
|
||||
- [Source configuration (`url`, `content`)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/source-configuration.ts#L8-L55)
|
||||
- [Full API reference configuration](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/api-reference-configuration.ts#L22-L379)
|
||||
|
||||
### Dynamic configuration (request-aware)
|
||||
|
||||
```typescript
|
||||
app.get('/scalar', Scalar((c) => ({
|
||||
url: '/openapi.json',
|
||||
proxyUrl: c.env.ENVIRONMENT === 'development'
|
||||
? 'https://proxy.scalar.com'
|
||||
: undefined,
|
||||
})))
|
||||
```
|
||||
|
||||
**Evidence** ([`integrations/hono/src/scalar.ts`](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/src/scalar.ts#L75-L94)):
|
||||
|
||||
```typescript
|
||||
export const Scalar = <E extends Env>(configOrResolver: Configuration<E>): MiddlewareHandler<E> => {
|
||||
return async (c) => {
|
||||
let resolvedConfig: Partial<ApiReferenceConfiguration> = {}
|
||||
if (typeof configOrResolver === 'function') {
|
||||
resolvedConfig = await configOrResolver(c)
|
||||
} else {
|
||||
resolvedConfig = configOrResolver
|
||||
}
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `@hono/swagger-ui` Setup
|
||||
|
||||
### Import path and package name
|
||||
|
||||
```typescript
|
||||
import { swaggerUI } from '@hono/swagger-ui'
|
||||
```
|
||||
|
||||
**Evidence** ([`packages/swagger-ui/src/index.ts`](https://github.com/honojs/middleware/blob/eb443a2fbda674bbe12d3f30e96854bb0cad6232/packages/swagger-ui/src/index.ts#L93)):
|
||||
|
||||
```typescript
|
||||
export { middleware as swaggerUI, SwaggerUI }
|
||||
```
|
||||
|
||||
### Mount path convention
|
||||
|
||||
Common choices:
|
||||
- `/swagger` — explicit
|
||||
- `/ui` — used in Hono official examples
|
||||
- `/docs` — generic
|
||||
|
||||
### Configuration options
|
||||
|
||||
```typescript
|
||||
app.get('/swagger', swaggerUI({
|
||||
url: '/openapi.json', // URL to the OpenAPI spec (required)
|
||||
title: 'Swagger UI', // HTML page title
|
||||
version: 'latest', // Swagger UI CDN version
|
||||
// Any standard Swagger UI option also works:
|
||||
// presets, plugins, urls, etc.
|
||||
}))
|
||||
```
|
||||
|
||||
**Evidence** ([`packages/swagger-ui/src/index.ts`](https://github.com/honojs/middleware/blob/eb443a2fbda674bbe12d3f30e96854bb0cad6232/packages/swagger-ui/src/index.ts#L8-L43)):
|
||||
|
||||
```typescript
|
||||
type SwaggerUIOptions = OriginalSwaggerUIOptions & DistSwaggerUIOptions
|
||||
|
||||
const middleware = <E extends Env>(options: SwaggerUIOptions): MiddlewareHandler<E> =>
|
||||
async (c) => {
|
||||
const title = options?.title ?? 'SwaggerUI'
|
||||
return c.html(/* html */ `...`)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Bun Runtime Entrypoint
|
||||
|
||||
### Canonical shape for `bun run`
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
// ... routes ...
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
**Evidence** ([Hono Bun docs](https://hono.dev/docs/getting-started/bun)):
|
||||
|
||||
> ```ts
|
||||
> import { Hono } from 'hono'
|
||||
> const app = new Hono()
|
||||
> app.get('/', (c) => c.text('Hello Bun!'))
|
||||
> export default app
|
||||
> ```
|
||||
|
||||
### Custom port
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence** ([Hono Bun docs — Change port number](https://hono.dev/docs/getting-started/bun)):
|
||||
|
||||
> ```ts
|
||||
> export default {
|
||||
> port: 3000,
|
||||
> fetch: app.fetch,
|
||||
> }
|
||||
> ```
|
||||
|
||||
### `package.json` scripts for Bun
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"build": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. OpenAPI Version
|
||||
|
||||
### `hono-openapi` emits **OpenAPI 3.1.0** by default
|
||||
|
||||
This is **hardcoded** in the source and **not configurable** at runtime:
|
||||
|
||||
**Evidence** ([`src/handler.ts` line 120](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/handler.ts#L120)):
|
||||
|
||||
```typescript
|
||||
return {
|
||||
openapi: "3.1.0",
|
||||
..._documentation,
|
||||
// ...
|
||||
} satisfies OpenAPIV3_1.Document;
|
||||
```
|
||||
|
||||
> If you need OpenAPI 3.0.x, you must post-process the generated spec or use `@hono/zod-openapi` (the older package) instead. The user explicitly requested `hono-openapi`, so document that 3.1.0 is the only output.
|
||||
|
||||
---
|
||||
|
||||
## 8. Zod v3 vs Zod v4
|
||||
|
||||
| Feature | Zod v3 | Zod v4 |
|
||||
|---------|--------|--------|
|
||||
| Standard Schema | ❌ No | ✅ Yes (native) |
|
||||
| `hono-openapi` extra deps | `zod-openapi@4` | None |
|
||||
| Import path | `import { z } from 'zod'` | `import { z } from 'zod'` (or `zod/v4` for explicit) |
|
||||
|
||||
**For Zod v3 users**, install the compatibility layer:
|
||||
|
||||
```bash
|
||||
npm install zod-openapi@4
|
||||
```
|
||||
|
||||
Then use `zod-openapi`'s `.openapi()` for metadata and `.meta({ ref: 'Name' })` for component references. `hono-openapi`'s `resolver()` will still work, but the underlying schema conversion relies on `zod-openapi@4`.
|
||||
|
||||
**Evidence** ([HonoHub Zod docs](https://honohub.dev/docs/openapi/zod)):
|
||||
|
||||
> "For zod v3, you can use the `zod-openapi` library. You need to install `zod-openapi@4` for this to work properly."
|
||||
|
||||
**For Zod v4 users** (recommended in 2026), no extra packages are needed. `z.date()` is automatically converted to `{ type: 'string', format: 'date-time' }`.
|
||||
|
||||
**Evidence** ([`src/middlewares.ts` Zod v4 date override](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L63-L71)):
|
||||
|
||||
```typescript
|
||||
const zodV4DateOverride = (ctx: { ... }) => {
|
||||
if (ctx.zodSchema._zod.def.type === "date") {
|
||||
ctx.jsonSchema.type = "string";
|
||||
ctx.jsonSchema.format = "date-time";
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Real-World Example
|
||||
|
||||
**NamesMT/starter-monorepo** — a public monorepo starter using `hono-openapi` + `@scalar/hono-api-reference` together:
|
||||
|
||||
- File: [`apps/backend/src/openAPI.ts`](https://github.com/NamesMT/starter-monorepo/blob/main/apps/backend/src/openAPI.ts)
|
||||
- Pattern: mounts spec at `/openapi/spec.json` and Scalar UI at `/openapi/ui`
|
||||
|
||||
```typescript
|
||||
import type { Hono } from 'hono'
|
||||
import { Scalar } from '@scalar/hono-api-reference'
|
||||
import { openAPIRouteHandler } from 'hono-openapi'
|
||||
|
||||
export function setupOpenAPI(app: Hono<any, any>, prefix = '/openapi') {
|
||||
app.get(
|
||||
`${prefix}/spec.json`,
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: `starter-monorepo's backend`,
|
||||
version: '1.0.0',
|
||||
description: 'My amazing API',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
app.get(
|
||||
`${prefix}/ui`,
|
||||
Scalar({
|
||||
theme: 'deepSpace',
|
||||
url: `${prefix}/spec.json`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: No public repo was found using all four (`hono-openapi` + `Scalar` + `swagger-ui` + `hono`) in a single file. The canonical combination in the wild is `hono-openapi` + `Scalar`. Adding `swagger-ui` is a trivial parallel mount (shown in the `app.ts` above).
|
||||
|
||||
---
|
||||
|
||||
## 10. Common Pitfalls
|
||||
|
||||
1. **Using `openAPISpecs` instead of `openAPIRouteHandler`**
|
||||
Some docs (e.g. HONC) use `openAPISpecs` — this is **not** the current export name. The correct function is `openAPIRouteHandler` ([source](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/index.ts#L1)).
|
||||
|
||||
2. **Importing from `hono-openapi/zod`**
|
||||
There are **no subpath exports**. Always import from `hono-openapi` directly.
|
||||
|
||||
3. **Forgetting `@hono/standard-validator`**
|
||||
It is a peer dependency of `hono-openapi`. Modern package managers (npm ≥ 7, pnpm, bun) auto-install it. If you see validation errors, ensure it is present in `node_modules`.
|
||||
|
||||
4. **Using `@hono/zod-openapi` (the OLD package)**
|
||||
The user explicitly wants `hono-openapi` (the newer, middleware-based, Standard Schema package). Do not confuse with `@hono/zod-openapi` which wraps the `Hono` class into `OpenAPIHono`.
|
||||
|
||||
5. **Swagger UI `spec` option**
|
||||
`@hono/swagger-ui` does **not** accept a `spec` option to embed the document directly. It only accepts `url` (or `urls`) pointing to an external spec endpoint. If you need embedded specs, use Scalar's `content` option instead.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick Start Commands
|
||||
|
||||
```bash
|
||||
# 1. Create project
|
||||
mkdir my-api && cd my-api
|
||||
bun init -y
|
||||
|
||||
# 2. Install dependencies
|
||||
bun add hono hono-openapi @scalar/hono-api-reference @hono/swagger-ui zod
|
||||
|
||||
# 3. Add TypeScript
|
||||
bun add -d typescript @types/bun
|
||||
|
||||
# 4. Write app.ts (copy from section 2 above)
|
||||
# 5. Run
|
||||
bun run --hot app.ts
|
||||
```
|
||||
|
||||
Endpoints after startup:
|
||||
- `GET /health` — health check
|
||||
- `GET /hello?name=world` — documented route
|
||||
- `POST /users` — validated JSON body route
|
||||
- `GET /openapi.json` — raw OpenAPI 3.1.0 spec
|
||||
- `GET /scalar` — Scalar API Reference UI
|
||||
- `GET /swagger` — Swagger UI
|
||||
@@ -0,0 +1,199 @@
|
||||
# Bootstrap — Runtime, Package Manager, Tooling
|
||||
|
||||
When starting a new TypeScript project (or scripting against the world), the choice of runtime, package manager, framework, and toolchain compounds. The wrong default at minute zero costs hours every week. The right defaults for 2026:
|
||||
|
||||
## Runtime decision tree
|
||||
|
||||
```
|
||||
Is this a CLI / script / single-binary tool?
|
||||
└─ Yes → Bun (single executable, hot reload, native TS)
|
||||
Use `bun run script.ts` directly. No build step.
|
||||
|
||||
Is this a backend service?
|
||||
├─ Edge (Cloudflare Workers / Vercel / Deno Deploy) → match the platform
|
||||
├─ Bun-supported runtime → Bun + Hono
|
||||
├─ Need Node-only deps (sharp, native modules without Bun support) → Node + Hono
|
||||
└─ Otherwise → Bun + Hono
|
||||
|
||||
Is this a frontend?
|
||||
└─ Vite (regardless of framework). Bun for the package manager.
|
||||
|
||||
Is this a library to publish to npm?
|
||||
└─ tsdown (or unbuild). Targets Node 20+. Use pnpm for monorepo workspaces.
|
||||
```
|
||||
|
||||
## Bun is the default runtime
|
||||
|
||||
Use Bun for:
|
||||
- Scripts and CLIs (`bun run` is faster than `tsx` and `ts-node`)
|
||||
- New backends (Hono runs natively, hot reload via `bun --hot`)
|
||||
- Test runner (`bun test` is built-in, faster than vitest for small suites)
|
||||
- Package manager (`bun install` is faster than `pnpm` and far faster than `npm`)
|
||||
|
||||
Use Node when:
|
||||
- A dependency uses native modules Bun can't load (rare in 2026; check the dep's release notes)
|
||||
- Production target is a Node-specific platform (some serverless platforms don't run Bun yet)
|
||||
- You're contributing to a Node-only project
|
||||
|
||||
`bunx` replaces `npx`. `bun create` scaffolds projects.
|
||||
|
||||
## Package manager — pnpm > npm
|
||||
|
||||
If you must use Node, use pnpm. NEVER npm except in legacy projects you don't control.
|
||||
|
||||
Why pnpm:
|
||||
- Content-addressable store: 10x less disk usage on a machine with many projects
|
||||
- Strict node_modules layout: phantom dependencies fail at install time, not at runtime
|
||||
- Workspaces are first-class
|
||||
- Significantly faster than npm
|
||||
|
||||
Why not yarn:
|
||||
- Yarn classic is unmaintained
|
||||
- Yarn berry's "PnP" mode breaks with editor tooling more often than it should
|
||||
- pnpm has caught up on every yarn berry feature people actually use
|
||||
|
||||
Why not npm:
|
||||
- Slowest of the three
|
||||
- No proper workspace story until very recently
|
||||
- Phantom dependencies allowed by default
|
||||
|
||||
```bash
|
||||
# Convert npm/yarn → pnpm
|
||||
pnpm import # reads package-lock.json or yarn.lock and produces pnpm-lock.yaml
|
||||
rm -rf node_modules package-lock.json yarn.lock
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Backend framework — Hono
|
||||
|
||||
Use Hono for any new HTTP service. It is:
|
||||
- Type-safe end-to-end (request/response types flow through middleware)
|
||||
- Edge-compatible (runs on Bun, Node, Cloudflare Workers, Deno, AWS Lambda)
|
||||
- Faster than Express, Fastify, and most of its peers in synthetic benchmarks
|
||||
- Maintained, opinionated, and documented well
|
||||
|
||||
When Hono → ALWAYS pair with `hono-openapi` + `@scalar/hono-api-reference` + `@hono/swagger-ui`. Full setup with copy-pasteable `app.ts`: [backend-hono.md](backend-hono.md).
|
||||
|
||||
NEVER:
|
||||
- Express for new services. Express is the COBOL of Node — works, but writes itself out of every benchmark.
|
||||
- Fastify for new services. Hono ships with better TypeScript ergonomics.
|
||||
- NestJS for new services. The Angular-flavoured DI/decorator stack is overkill for ~95% of services.
|
||||
- Bare `Bun.serve` or `node:http` unless you have a specific reason. Lose middleware, routing, validation. Reinvent everything.
|
||||
|
||||
## Frontend tooling — Vite
|
||||
|
||||
Vite for any frontend. Replaces webpack, parcel, rollup-as-app-bundler. Works with React, Vue, Svelte, Solid, Preact, vanilla.
|
||||
|
||||
```bash
|
||||
bun create vite my-app -- --template react-ts
|
||||
cd my-app
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Lint + format — Biome
|
||||
|
||||
Biome replaces ESLint + Prettier with one tool, written in Rust, ~30x faster.
|
||||
|
||||
```bash
|
||||
bun add --dev @biomejs/biome
|
||||
bun biome init
|
||||
```
|
||||
|
||||
`biome.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
"organizeImports": { "enabled": true },
|
||||
"linter": { "enabled": true, "rules": { "recommended": true } },
|
||||
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
Use ESLint only when:
|
||||
- You have an ESLint plugin Biome doesn't replicate (rare in 2026)
|
||||
- You're contributing to an existing ESLint project
|
||||
|
||||
Never run both — pick one.
|
||||
|
||||
## Test runner — bun test or vitest
|
||||
|
||||
| Runner | Use when |
|
||||
|---|---|
|
||||
| `bun test` | Bun project, simple unit tests, no TypeScript path aliases that need vite-style resolution |
|
||||
| `vitest` | Vite-based frontend, complex test infrastructure (DOM testing, snapshot, in-browser tests), or you need vitest-specific features |
|
||||
|
||||
NEVER Jest for a new project. Jest's CommonJS-first design fights every modern Node/TS project.
|
||||
|
||||
## TypeScript
|
||||
|
||||
`tsconfig.json` for a Bun + Hono backend:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
`verbatimModuleSyntax: true` enforces explicit `import type { ... }` for type-only imports — pairs with the no-excuse rule on type-only imports.
|
||||
|
||||
`noEmit: true` because `bun run` and `bun build` handle compilation. The `tsc` command becomes a typechecker only.
|
||||
|
||||
## Quick-start: Bun + Hono backend
|
||||
|
||||
```bash
|
||||
mkdir my-api && cd my-api
|
||||
bun init -y
|
||||
bun add hono hono-openapi @scalar/hono-api-reference @hono/swagger-ui zod
|
||||
bun add --dev @biomejs/biome typescript
|
||||
bun biome init
|
||||
```
|
||||
|
||||
`package.json` scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"build": "bun build src/index.ts --target bun --outdir dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --write src tests",
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wire the `app.ts` from [backend-hono.md](backend-hono.md). You have a documented, validated, OpenAPI-spec-emitting service in ~15 minutes.
|
||||
|
||||
## When NOT to bootstrap from scratch
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| Internal tool with auth/admin/dashboards | Next.js (full-stack) - lots of free wiring |
|
||||
| Documentation site | Astro or VitePress |
|
||||
| Real-time features (WebRTC, complex sockets) | Bun + Hono + a real-time library |
|
||||
| Data-heavy SPA | Vite + React + TanStack Query + TanStack Router |
|
||||
|
||||
For greenfield backend services, Bun + Hono. Always.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Data Modeling
|
||||
|
||||
Which construct to use, how to structure data, and why readonly is the default.
|
||||
|
||||
---
|
||||
|
||||
## Decision flowchart
|
||||
|
||||
```
|
||||
Is it a fixed set of named constants?
|
||||
YES → as const object + literal union type
|
||||
NO ↓
|
||||
Is it just branding a primitive (string, number)?
|
||||
YES → Branded type
|
||||
NO ↓
|
||||
Is it an interface / contract?
|
||||
YES → interface (structural typing is the default in TS)
|
||||
NO ↓
|
||||
Does the data cross a trust boundary (user input, API, file)?
|
||||
YES → Zod schema + z.infer<typeof schema>
|
||||
NO ↓
|
||||
Is it a union of possible outcomes?
|
||||
YES → Discriminated union (kind/type field)
|
||||
NO ↓
|
||||
Is it structured data with named fields?
|
||||
YES → type alias with readonly properties
|
||||
NO → you probably don't need a new type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container reference
|
||||
|
||||
### type alias — internal data
|
||||
|
||||
The default for structured data inside your codebase. Zero runtime cost.
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
readonly id: UserId
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
}
|
||||
|
||||
type Point = {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
```
|
||||
|
||||
All properties `readonly`. Mutable only when mutation is the documented purpose.
|
||||
|
||||
### interface — contracts and extension
|
||||
|
||||
Use when you need declaration merging or `extends`.
|
||||
|
||||
```typescript
|
||||
interface Repository<T> {
|
||||
get(id: string): Promise<T | null>
|
||||
save(entity: T): Promise<void>
|
||||
}
|
||||
|
||||
interface UserRepository extends Repository<User> {
|
||||
findByEmail(email: string): Promise<User | null>
|
||||
}
|
||||
```
|
||||
|
||||
### interface vs type — when to use which
|
||||
|
||||
| Use | When |
|
||||
|---|---|
|
||||
| `type` | Union types, intersections, mapped types, utility types, internal data shapes |
|
||||
| `interface` | Contracts that will be `implements`ed or `extends`ed, declaration merging needed |
|
||||
| **Default** | **`type` — unless you have a specific reason for `interface`** |
|
||||
|
||||
### Zod schema — trust boundary guardian
|
||||
|
||||
Use when data enters your system. Validates at runtime, infers types at compile time.
|
||||
|
||||
```typescript
|
||||
import { z } from "zod"
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(0),
|
||||
})
|
||||
type CreateUser = z.infer<typeof CreateUserSchema>
|
||||
|
||||
const UserResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
type UserResponse = z.infer<typeof UserResponseSchema>
|
||||
```
|
||||
|
||||
**The one rule**: data crosses a trust boundary → Zod. Everything else → plain type/interface.
|
||||
Never use Zod for internal-only data. The runtime validation cost and Zod coupling are unnecessary.
|
||||
|
||||
### as const — fixed constants
|
||||
|
||||
Replaces `enum` entirely. Type-safe, tree-shakeable, no runtime overhead.
|
||||
|
||||
```typescript
|
||||
const ROLES = ["admin", "user", "guest"] as const
|
||||
type Role = (typeof ROLES)[number]
|
||||
|
||||
const STATUS = {
|
||||
ACTIVE: "active",
|
||||
INACTIVE: "inactive",
|
||||
DELETED: "deleted",
|
||||
} as const
|
||||
type Status = (typeof STATUS)[keyof typeof STATUS]
|
||||
```
|
||||
|
||||
### Discriminated union — multiple outcomes
|
||||
|
||||
```typescript
|
||||
type GetUserResult =
|
||||
| { readonly kind: "found"; readonly user: User }
|
||||
| { readonly kind: "not_found"; readonly id: UserId }
|
||||
| { readonly kind: "forbidden"; readonly reason: string }
|
||||
```
|
||||
|
||||
Each variant has a `kind` discriminant. TypeScript narrows on `switch (result.kind)`.
|
||||
|
||||
---
|
||||
|
||||
## Quick lookup
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| User input, API request/response | Zod schema + `z.infer` |
|
||||
| Internal value object | `type` with `readonly` properties |
|
||||
| Function with multiple outcomes | Discriminated union |
|
||||
| Contract for implementations | `interface` |
|
||||
| Fixed constants | `as const` + literal union |
|
||||
| Distinct primitive (UserId vs OrderId) | Branded type |
|
||||
| Dict shape / key-value map | `Record<K, V>` or index signature |
|
||||
|
||||
---
|
||||
|
||||
## Readonly by default
|
||||
|
||||
Every property is `readonly` unless mutation is the documented purpose.
|
||||
|
||||
```typescript
|
||||
// DEFAULT — readonly
|
||||
type Config = {
|
||||
readonly apiUrl: string
|
||||
readonly timeout: number
|
||||
}
|
||||
|
||||
// Arrays too
|
||||
function getUsers(): readonly User[] { ... }
|
||||
|
||||
// Utility for existing types
|
||||
type ReadonlyUser = Readonly<User>
|
||||
type DeepReadonlyConfig = Readonly<Config>
|
||||
```
|
||||
|
||||
For mutable state (rare), document why:
|
||||
|
||||
```typescript
|
||||
/** Counter state — mutation is the entire purpose. */
|
||||
type CounterState = {
|
||||
count: number // intentionally mutable
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parse, don't validate
|
||||
|
||||
Validate at the boundary. Inside the boundary, types are proof of validity.
|
||||
|
||||
```typescript
|
||||
// BAD — validate then pass raw data
|
||||
function processEmail(email: string): void {
|
||||
if (!email.includes("@")) throw new Error("invalid")
|
||||
// still a raw string downstream
|
||||
}
|
||||
|
||||
// GOOD — parse into typed value at boundary
|
||||
const EmailSchema = z.string().email().brand("Email")
|
||||
type Email = z.infer<typeof EmailSchema>
|
||||
|
||||
function sendWelcome(email: Email): void { ... }
|
||||
|
||||
// Boundary code
|
||||
const parsed = EmailSchema.parse(rawInput) // Email or throws
|
||||
sendWelcome(parsed) // no re-validation needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- TypeScript Handbook: [Object Types](https://www.typescriptlang.org/docs/handbook/2/objects.html)
|
||||
- Zod: [docs](https://zod.dev)
|
||||
- Total TypeScript: [Type vs Interface](https://www.totaltypescript.com/type-vs-interface-which-should-you-use)
|
||||
@@ -0,0 +1,169 @@
|
||||
# Error Handling
|
||||
|
||||
Typed errors, exhaustive matching, Result pattern, and resource safety.
|
||||
|
||||
---
|
||||
|
||||
## Typed errors — no bare strings
|
||||
|
||||
Error classes carry structured data. Callers know exactly what can go wrong.
|
||||
|
||||
```typescript
|
||||
class UserNotFoundError extends Error {
|
||||
readonly name = "UserNotFoundError"
|
||||
constructor(readonly userId: UserId) {
|
||||
super(`user ${userId} not found`)
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedError extends Error {
|
||||
readonly name = "PermissionDeniedError"
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
readonly requiredRole: string,
|
||||
) {
|
||||
super(`user ${userId} needs role ${requiredRole}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
throw new Error("user not found")
|
||||
throw new Error("permission denied")
|
||||
|
||||
// GOOD
|
||||
throw new UserNotFoundError(userId)
|
||||
throw new PermissionDeniedError(userId, "admin")
|
||||
```
|
||||
|
||||
Always set `readonly name` explicitly — `instanceof` checks survive minification, but `error.name` is more reliable for logging and serialization.
|
||||
|
||||
---
|
||||
|
||||
## Result pattern — expected failures without exceptions
|
||||
|
||||
For failures that are **expected** (not found, validation), return a discriminated union instead of throwing.
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: E }
|
||||
|
||||
function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error }
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
type UserError =
|
||||
| { readonly kind: "not_found"; readonly id: UserId }
|
||||
| { readonly kind: "forbidden"; readonly reason: string }
|
||||
|
||||
function getUser(id: UserId): Result<User, UserError> {
|
||||
const user = db.find(id)
|
||||
if (!user) return err({ kind: "not_found", id })
|
||||
if (!user.active) return err({ kind: "forbidden", reason: "deactivated" })
|
||||
return ok(user)
|
||||
}
|
||||
|
||||
// Caller must handle both cases
|
||||
const result = getUser(userId)
|
||||
if (!result.ok) {
|
||||
switch (result.error.kind) {
|
||||
case "not_found":
|
||||
log.warn(`missing: ${result.error.id}`)
|
||||
break
|
||||
case "forbidden":
|
||||
log.error(`denied: ${result.error.reason}`)
|
||||
break
|
||||
default:
|
||||
assertNever(result.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
const user = result.value // narrowed to User
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
**The heuristic**: caller is 1-2 levels away and MUST handle it → Result. Error should propagate up many layers → throw.
|
||||
|
||||
| Scenario | Pattern | Why |
|
||||
|---|---|---|
|
||||
| Repository → service (caller handles it) | Result | Caller is right there, must handle both |
|
||||
| Validation at boundary (parsing input) | throw (Zod throws) | Propagates up to HTTP handler |
|
||||
| Infrastructure failure (network, OOM) | throw | Can't handle locally |
|
||||
| Service → service (deep internal) | throw (typed Error subclass) | Result boilerplate across many layers is worse |
|
||||
| HTTP handler → response | Catch errors, convert to response | Boundary code catches and translates |
|
||||
|
||||
**Practical tradeoff**: Result is safest (compiler forces handling) but creates boilerplate when every caller in a chain must check `.ok`. If the error would just propagate through 3+ layers unchanged, use a typed Error subclass instead.
|
||||
|
||||
### Library or roll your own?
|
||||
|
||||
Roll your own with the `Result`, `ok`, `err` above. It's 10 lines. Libraries like `neverthrow` add chaining (`.map`, `.andThen`) — use them only if you actually chain results frequently.
|
||||
|
||||
---
|
||||
|
||||
## Error cause — chain context
|
||||
|
||||
Use the `cause` option to chain errors without losing the original stack.
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await db.query(sql)
|
||||
} catch (error) {
|
||||
throw new DatabaseError("query failed", { cause: error })
|
||||
}
|
||||
```
|
||||
|
||||
The `cause` is available on `error.cause` and shows up in stack traces.
|
||||
|
||||
---
|
||||
|
||||
## Exhaustive error handling at boundaries
|
||||
|
||||
HTTP handlers catch and translate:
|
||||
|
||||
```typescript
|
||||
app.onError((error, c) => {
|
||||
if (error instanceof UserNotFoundError) {
|
||||
return c.json({ error: error.message }, 404)
|
||||
}
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return c.json({ error: error.message }, 403)
|
||||
}
|
||||
console.error("unhandled:", error)
|
||||
return c.json({ error: "internal server error" }, 500)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async error patterns
|
||||
|
||||
```typescript
|
||||
// Promise.allSettled — when partial failure is OK
|
||||
const results = await Promise.allSettled(urls.map(fetch))
|
||||
const successes = results
|
||||
.filter((r): r is PromiseFulfilledResult<Response> => r.status === "fulfilled")
|
||||
.map((r) => r.value)
|
||||
|
||||
// AbortSignal — cancellation
|
||||
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
|
||||
return fetch(url, { signal: AbortSignal.timeout(ms) })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- MDN: [Error cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
- MDN: [Promise.allSettled](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled)
|
||||
@@ -0,0 +1,152 @@
|
||||
# Strict tsconfig + Biome
|
||||
|
||||
The canonical ultra-strict config. Copy-paste, then add your own paths.
|
||||
|
||||
---
|
||||
|
||||
## tsconfig.json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"compilerOptions": {
|
||||
// ── Strict core ──────────────────────────────────────────
|
||||
"strict": true, // enables all strict* flags below
|
||||
// strict includes: strictNullChecks, strictFunctionTypes,
|
||||
// strictBindCallApply, strictPropertyInitialization,
|
||||
// noImplicitAny, noImplicitThis, alwaysStrict, useUnknownInCatchVariables
|
||||
|
||||
// ── Additional strict flags (NOT included in "strict") ──
|
||||
"noUncheckedIndexedAccess": true, // obj[key] is T | undefined, not T
|
||||
"exactOptionalPropertyTypes": true, // { x?: string } !== { x: string | undefined }
|
||||
"noFallthroughCasesInSwitch": true, // switch fall-through is an error
|
||||
"noPropertyAccessFromIndexSignature": true, // forces bracket notation for index sigs
|
||||
"forceConsistentCasingInFileNames": true, // prevents case-sensitivity bugs on macOS/Win
|
||||
|
||||
// ── Module system ────────────────────────────────────────
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true, // forces `import type` for type-only imports
|
||||
"isolatedModules": true, // safe for esbuild / swc / Bun transpilation
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
// ── Target ───────────────────────────────────────────────
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
|
||||
// ── Emit ─────────────────────────────────────────────────
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
// ── Performance ──────────────────────────────────────────
|
||||
"skipLibCheck": true, // skip checking .d.ts files for speed
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
### What each extra flag catches
|
||||
|
||||
| Flag | What it prevents |
|
||||
|---|---|
|
||||
| `noUncheckedIndexedAccess` | `arr[0]` is `T \| undefined`, not `T`. Forces you to check before using. |
|
||||
| `exactOptionalPropertyTypes` | `{ x?: string }` means "missing or string", NOT "string \| undefined". Assigns `undefined` explicitly? Type error. |
|
||||
| `noFallthroughCasesInSwitch` | Forgetting `break` / `return` in a switch case. |
|
||||
| `noPropertyAccessFromIndexSignature` | `obj.foo` on `Record<string, X>` is an error. Use `obj["foo"]`. |
|
||||
| `verbatimModuleSyntax` | Forces `import type { X }` for type-only imports. Prevents runtime import of types. |
|
||||
|
||||
### Bun-specific additions
|
||||
|
||||
For Bun projects, add to `compilerOptions`:
|
||||
```jsonc
|
||||
{
|
||||
"types": ["bun-types"],
|
||||
"moduleDetection": "force"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## biome.jsonc
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noConfusingVoidType": "error",
|
||||
"noFallthroughSwitchClause": "error"
|
||||
},
|
||||
"style": {
|
||||
"noDefaultExport": "error",
|
||||
"useImportType": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useEnumInitializers": "off",
|
||||
"noParameterAssign": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Biome rules
|
||||
|
||||
| Rule | What |
|
||||
|---|---|
|
||||
| `noExplicitAny` | `any` in annotations is an error |
|
||||
| `noNonNullAssertion` | `x!` is an error |
|
||||
| `noDefaultExport` | Forces named exports |
|
||||
| `useImportType` | Forces `import type` for type-only imports |
|
||||
| `noParameterAssign` | No mutation of function parameters |
|
||||
|
||||
---
|
||||
|
||||
## CI gate
|
||||
|
||||
```bash
|
||||
bunx biome check .
|
||||
bunx tsc --noEmit
|
||||
bun test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- TypeScript: [tsconfig reference](https://www.typescriptlang.org/tsconfig)
|
||||
- Biome: [configuration](https://biomejs.dev/reference/configuration/)
|
||||
- Total TypeScript: [tsconfig cheat sheet](https://www.totaltypescript.com/tsconfig-cheat-sheet)
|
||||
@@ -0,0 +1,196 @@
|
||||
# Type Patterns
|
||||
|
||||
How to use TypeScript's type system to catch bugs at compile time.
|
||||
|
||||
---
|
||||
|
||||
## Branded types — distinct primitives
|
||||
|
||||
Same runtime type, different meaning. The compiler prevents mixing.
|
||||
|
||||
```typescript
|
||||
declare const brand: unique symbol
|
||||
type Brand<T, B extends string> = T & { readonly [brand]: B }
|
||||
|
||||
type UserId = Brand<string, "UserId">
|
||||
type OrderId = Brand<string, "OrderId">
|
||||
type Milliseconds = Brand<number, "Milliseconds">
|
||||
type Seconds = Brand<number, "Seconds">
|
||||
|
||||
function UserId(value: string): UserId { return value as UserId }
|
||||
function OrderId(value: string): OrderId { return value as OrderId }
|
||||
|
||||
function getUser(id: UserId): User { ... }
|
||||
|
||||
getUser(UserId("abc")) // OK
|
||||
getUser(OrderId("abc")) // type error: OrderId is not UserId
|
||||
getUser("abc") // type error: string is not UserId
|
||||
```
|
||||
|
||||
With Zod (preferred at boundaries):
|
||||
```typescript
|
||||
import { z } from "zod"
|
||||
|
||||
const UserIdSchema = z.string().uuid().brand("UserId")
|
||||
type UserId = z.infer<typeof UserIdSchema>
|
||||
```
|
||||
|
||||
**Use when**: IDs, indices, units of measurement — any pair where swapping is a bug.
|
||||
|
||||
---
|
||||
|
||||
## as const — literal types from values
|
||||
|
||||
Freezes a value to its narrowest possible type. The foundation for enum-free TypeScript.
|
||||
|
||||
```typescript
|
||||
const ROLES = ["admin", "user", "guest"] as const
|
||||
type Role = (typeof ROLES)[number] // "admin" | "user" | "guest"
|
||||
|
||||
const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL: 500,
|
||||
} as const
|
||||
type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS] // 200 | 404 | 500
|
||||
```
|
||||
|
||||
**Use when**: fixed set of constants. Replaces `enum` entirely.
|
||||
**Skip when**: the set is open-ended or user-defined.
|
||||
|
||||
---
|
||||
|
||||
## satisfies — validate without widening
|
||||
|
||||
Type-checks a value against a type while preserving the literal type. Best of both worlds.
|
||||
|
||||
```typescript
|
||||
type Config = Record<string, string | number>
|
||||
|
||||
// BAD — widens to Record<string, string | number>
|
||||
const config: Config = { api: "https://api.example.com", timeout: 30 }
|
||||
config.api // string | number — lost the narrowing
|
||||
|
||||
// GOOD — validates AND preserves literal types
|
||||
const config = {
|
||||
api: "https://api.example.com",
|
||||
timeout: 30,
|
||||
} satisfies Config
|
||||
config.api // string (narrowed)
|
||||
config.timeout // number (narrowed)
|
||||
```
|
||||
|
||||
**Use when**: you want type validation on a value without losing narrowing.
|
||||
|
||||
---
|
||||
|
||||
## Discriminated unions — algebraic data types
|
||||
|
||||
Model every outcome as a type. Force the caller to handle all cases.
|
||||
|
||||
```typescript
|
||||
type GetUserResult =
|
||||
| { readonly kind: "found"; readonly user: User }
|
||||
| { readonly kind: "not_found"; readonly id: UserId }
|
||||
| { readonly kind: "forbidden"; readonly reason: string }
|
||||
```
|
||||
|
||||
The `kind` field (or `type`, `status`, `_tag`) is the discriminant. TypeScript narrows on it automatically.
|
||||
|
||||
---
|
||||
|
||||
## Exhaustive switch — assertNever
|
||||
|
||||
Every switch on a discriminated union ends with a default that calls `assertNever`.
|
||||
|
||||
```typescript
|
||||
function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected value: ${JSON.stringify(x)}`)
|
||||
}
|
||||
|
||||
function handleResult(result: GetUserResult): string {
|
||||
switch (result.kind) {
|
||||
case "found":
|
||||
return result.user.name
|
||||
case "not_found":
|
||||
return `No user ${result.id}`
|
||||
case "forbidden":
|
||||
return `Denied: ${result.reason}`
|
||||
default:
|
||||
return assertNever(result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a new variant to `GetUserResult`? The compiler errors on the `assertNever` call until you handle it.
|
||||
|
||||
---
|
||||
|
||||
## Narrowing — let the compiler follow your logic
|
||||
|
||||
TypeScript narrows types through `typeof`, `instanceof`, `in`, equality checks, and discriminants.
|
||||
|
||||
```typescript
|
||||
function process(value: string | number | null): string {
|
||||
if (value === null) return "nothing"
|
||||
// compiler knows: string | number
|
||||
|
||||
if (typeof value === "string") return value.toUpperCase()
|
||||
// compiler knows: number
|
||||
|
||||
return String(value * 2)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom type guards
|
||||
|
||||
```typescript
|
||||
function isNonNull<T>(value: T | null | undefined): value is T {
|
||||
return value != null
|
||||
}
|
||||
|
||||
const items = [1, null, 2, undefined, 3]
|
||||
const clean = items.filter(isNonNull) // number[]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## import type — separate values from types
|
||||
|
||||
Always use `import type` for type-only imports. Enforced by `verbatimModuleSyntax`.
|
||||
|
||||
```typescript
|
||||
import type { User, Config } from "./types" // erased at runtime
|
||||
import { createUser } from "./services" // kept at runtime
|
||||
```
|
||||
|
||||
For mixed imports:
|
||||
```typescript
|
||||
import { createUser, type User } from "./users"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Utility types — quick reference
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| All properties readonly | `Readonly<T>` |
|
||||
| All properties optional | `Partial<T>` |
|
||||
| All properties required | `Required<T>` |
|
||||
| Pick specific properties | `Pick<T, "a" \| "b">` |
|
||||
| Omit specific properties | `Omit<T, "a" \| "b">` |
|
||||
| Key-value map | `Record<K, V>` |
|
||||
| Extract from union | `Extract<T, U>` |
|
||||
| Exclude from union | `Exclude<T, U>` |
|
||||
| Return type of function | `ReturnType<typeof fn>` |
|
||||
| Parameters of function | `Parameters<typeof fn>` |
|
||||
| Awaited type | `Awaited<Promise<T>>` → `T` |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- TypeScript Handbook: [Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
|
||||
- TypeScript Handbook: [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)
|
||||
- Total TypeScript: [as const](https://www.totaltypescript.com/as-const)
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# No-excuse rule checker for Go files.
|
||||
# Mirrors the philosophy of python-programmer / typescript-programmer / rust-programmer scripts:
|
||||
# only rules that can be enforced via pure text matching live here.
|
||||
# Everything semantic is on golangci-lint + nilaway + go test -race.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file.go> [file.go ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
violations=0
|
||||
report() {
|
||||
local file="$1"
|
||||
local line="$2"
|
||||
local rule="$3"
|
||||
local detail="$4"
|
||||
echo "::error file=${file},line=${line}::[${rule}] ${detail}" >&2
|
||||
violations=$((violations + 1))
|
||||
}
|
||||
|
||||
is_test_file() {
|
||||
case "$1" in
|
||||
*_test.go) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
is_generated_file() {
|
||||
local file="$1"
|
||||
case "$file" in
|
||||
*.pb.go|*.connect.go|*.gen.go) return 0 ;;
|
||||
*_string.go) return 0 ;;
|
||||
esac
|
||||
# First-line check for "Code generated ... DO NOT EDIT." (the official marker)
|
||||
if [ -f "$file" ]; then
|
||||
head -n 5 "$file" 2>/dev/null | grep -qE "^// Code generated .* DO NOT EDIT\.$" && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
for file in "$@"; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.go) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
if is_generated_file "$file"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
in_test=0
|
||||
if is_test_file "$file"; then
|
||||
in_test=1
|
||||
fi
|
||||
|
||||
line_no=0
|
||||
while IFS= read -r raw_line || [ -n "$raw_line" ]; do
|
||||
line_no=$((line_no + 1))
|
||||
line="$raw_line"
|
||||
|
||||
# Strip line comments before pattern checks
|
||||
# (block comments are not handled — keep the rules robust to that limitation).
|
||||
code_only="${line%%//*}"
|
||||
|
||||
# ── Exemption marker: // no-excuse-ok: <reason> ──────────────────
|
||||
if [[ "$line" =~ //[[:space:]]*no-excuse-ok:[[:space:]]*.+ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# ── Rule: no `_ = err` (silent error swallow) ────────────────────
|
||||
# The errcheck linter catches most of these but the `_ = err` form
|
||||
# specifically slips through if used with named returns.
|
||||
if [[ "$code_only" =~ ^[[:space:]]*_[[:space:]]*=[[:space:]]*err[[:space:]]*$ ]] ||
|
||||
[[ "$code_only" =~ ^[[:space:]]*_[[:space:]]*=[[:space:]]*err[[:space:]]*[^a-zA-Z0-9_].*$ ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
report "$file" "$line_no" "silent-err" "discarding err with '_ = err' — handle the error"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no `panic(` in non-test, non-main code ─────────────────
|
||||
# Allowed in main(), allowed in tests, allowed with explicit marker.
|
||||
if [[ "$code_only" =~ [^a-zA-Z0-9_]panic\( ]] || [[ "$code_only" =~ ^[[:space:]]*panic\( ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
# main package main.go is the one exception
|
||||
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
|
||||
if [[ "$pkg_line" != "package main" ]]; then
|
||||
report "$file" "$line_no" "panic-in-lib" "panic outside main/test — return error instead"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no `log.Fatal` / `log.Panic` in library code ───────────
|
||||
if [[ "$code_only" =~ log\.(Fatal|Panic)(f|ln)?\( ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
|
||||
if [[ "$pkg_line" != "package main" ]]; then
|
||||
report "$file" "$line_no" "log-fatal-in-lib" "log.Fatal/Panic outside main — return error"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no init() functions ─────────────────────────────────
|
||||
# init() ruins testability and creates hidden global state.
|
||||
# Exception: //go:build constraint files and generated code.
|
||||
if [[ "$code_only" =~ ^func[[:space:]]+init\(\)[[:space:]]*\{ ]]; then
|
||||
report "$file" "$line_no" "no-init-func" "init() ruins testability — use explicit constructor"
|
||||
fi
|
||||
|
||||
# ── Rule: no `time.Sleep` in non-test code ──────────────────────
|
||||
if [[ "$code_only" =~ time\.Sleep\( ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
report "$file" "$line_no" "time-sleep" "time.Sleep in production code — use ticker/timer with ctx"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no `context.Background()` inside functions (only in main/init/test) ──
|
||||
if [[ "$code_only" =~ context\.Background\(\) ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
|
||||
if [[ "$pkg_line" != "package main" ]]; then
|
||||
report "$file" "$line_no" "ctx-background-in-lib" "context.Background() outside main — propagate ctx as parameter"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no `interface{}` (use `any`, the alias from Go 1.18+) ──
|
||||
if [[ "$code_only" =~ interface\{\} ]]; then
|
||||
report "$file" "$line_no" "old-interface-empty" "use 'any' instead of 'interface{}' (Go 1.18+)"
|
||||
fi
|
||||
|
||||
# ── Rule: no bare `fmt.Println` for logging (use slog) ───────────
|
||||
# Acceptable in main.go (CLI output) and tests. Reject in libraries.
|
||||
if [[ "$code_only" =~ fmt\.(Print|Println|Printf)\( ]]; then
|
||||
if [ "$in_test" -eq 0 ]; then
|
||||
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
|
||||
if [[ "$pkg_line" != "package main" ]]; then
|
||||
report "$file" "$line_no" "fmt-print-in-lib" "fmt.Print* in library — use slog for structured logs"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no `nolint` directive without reason ───────────────────
|
||||
if [[ "$line" =~ //nolint(:|$| ) ]]; then
|
||||
if ! [[ "$line" =~ //nolint:[a-zA-Z0-9_,-]+[[:space:]]+//[[:space:]]*[^[:space:]] ]]; then
|
||||
report "$file" "$line_no" "nolint-no-reason" "//nolint requires a // reason after the linter list"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rule: no TODO / FIXME without an issue link or owner ─────────
|
||||
# Check the full line — TODOs live in comments, which $code_only has stripped.
|
||||
if echo "$line" | grep -qE '(TODO|FIXME|XXX)([[:space:]]|:)'; then
|
||||
if ! echo "$line" | grep -qE '(TODO|FIXME|XXX).*[(@[]'; then
|
||||
report "$file" "$line_no" "todo-no-owner" "TODO/FIXME requires (#issue) or @owner attribution"
|
||||
fi
|
||||
fi
|
||||
done < "$file"
|
||||
done
|
||||
|
||||
if [ "$violations" -gt 0 ]; then
|
||||
echo "" >&2
|
||||
echo "go-programmer: $violations violation(s). Run also:" >&2
|
||||
echo " gofumpt -l ." >&2
|
||||
echo " golangci-lint run --timeout 5m ./..." >&2
|
||||
echo " nilaway ./..." >&2
|
||||
echo " go test -race -shuffle=on -count=1 ./..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "go-programmer: no-excuse rules passed for $# file(s)."
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-project.py myservice
|
||||
# uv run new-project.py myservice --module github.com/your-org/myservice
|
||||
# ──────────────────
|
||||
#
|
||||
# Creates a new Go project with the canonical strict layout:
|
||||
# - go.mod with go 1.23
|
||||
# - .golangci.yml (v2, strict bundle)
|
||||
# - Taskfile.yml (fmt + lint + test + build)
|
||||
# - cmd/server/main.go entrypoint
|
||||
# - internal/{cmd,config,api,domain,obs} skeletons
|
||||
# - .github/workflows/ci.yml
|
||||
#
|
||||
# Templates live in ./templates/ — keep this script under 250 pure LOC.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(stderr=True)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
|
||||
def _render(template_file: str, **subs: str) -> str:
|
||||
"""Read a template file and apply $placeholder substitutions.
|
||||
|
||||
Uses string.Template ($name) so Go/YAML curly braces stay literal.
|
||||
"""
|
||||
raw = (TEMPLATES_DIR / template_file).read_text()
|
||||
if not subs:
|
||||
return raw
|
||||
return Template(raw).substitute(**subs)
|
||||
|
||||
|
||||
# (template-file → relative output path; is_format = .format() is run)
|
||||
FILES: list[tuple[str, str, bool]] = [
|
||||
(".golangci.yml", ".golangci.yml", False),
|
||||
("Taskfile.yml", "Taskfile.yml", False),
|
||||
(".editorconfig", ".editorconfig", False),
|
||||
("gitignore", ".gitignore", False),
|
||||
("ci.yml", ".github/workflows/ci.yml", False),
|
||||
("run.go", "internal/cmd/run.go", False),
|
||||
("config.go", "internal/config/config.go", False),
|
||||
("main.go.tmpl", "cmd/server/main.go", True),
|
||||
("AGENTS.md.tmpl", "AGENTS.md", True),
|
||||
("README.md.tmpl", "README.md", True),
|
||||
]
|
||||
|
||||
|
||||
def _init_go_module(project_dir: Path, module: str) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["go", "mod", "init", module],
|
||||
cwd=project_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
console.print(f" [dim]ran[/] go mod init {module}")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
console.print(f" [yellow]warn[/] go mod init failed ({e}); writing fallback go.mod")
|
||||
(project_dir / "go.mod").write_text(f"module {module}\n\ngo 1.23\n")
|
||||
|
||||
|
||||
def _create_layout(project_dir: Path) -> None:
|
||||
"""Create the canonical internal/ tree."""
|
||||
subdirs = [
|
||||
"cmd/server",
|
||||
"internal/cmd",
|
||||
"internal/config",
|
||||
"internal/api",
|
||||
"internal/domain",
|
||||
"internal/obs",
|
||||
".github/workflows",
|
||||
]
|
||||
for sd in subdirs:
|
||||
(project_dir / sd).mkdir(parents=True)
|
||||
|
||||
|
||||
def _write_files(project_dir: Path, name: str, module: str, purpose: str) -> None:
|
||||
"""Render every template into the project tree."""
|
||||
for tmpl_name, out_rel, is_format in FILES:
|
||||
subs = (
|
||||
{"name": name, "module": module, "short_purpose": purpose}
|
||||
if is_format
|
||||
else {}
|
||||
)
|
||||
content = _render(tmpl_name, **subs)
|
||||
out_path = project_dir / out_rel
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(content)
|
||||
console.print(f" [dim]wrote[/] {out_rel}")
|
||||
|
||||
|
||||
def main(
|
||||
name: str,
|
||||
path: str = typer.Option(".", help="Parent dir"),
|
||||
module: str = typer.Option("", help="Go module path; default: <name>"),
|
||||
purpose: str = typer.Option("HTTP", help="Short purpose for AGENTS.md"),
|
||||
) -> None:
|
||||
"""Scaffold a new Go project with the strict toolchain."""
|
||||
project_dir = Path(path) / name
|
||||
if project_dir.exists():
|
||||
console.print(f"[red]✗[/red] {project_dir} already exists")
|
||||
sys.exit(1)
|
||||
|
||||
module_path = module or name
|
||||
|
||||
project_dir.mkdir(parents=True)
|
||||
_create_layout(project_dir)
|
||||
_init_go_module(project_dir, module_path)
|
||||
_write_files(project_dir, name, module_path, purpose)
|
||||
|
||||
console.print(f"\n[bold green]Done![/] cd {project_dir}")
|
||||
console.print(" go get github.com/caarlos0/env/v11")
|
||||
console.print(" task # fmt + lint + test")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml,json,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,95 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- nilerr
|
||||
- nilnil
|
||||
- bodyclose
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- contextcheck
|
||||
- fatcontext
|
||||
- copyloopvar
|
||||
- intrange
|
||||
- usetesting
|
||||
- testifylint
|
||||
- gofumpt
|
||||
- goimports
|
||||
- whitespace
|
||||
- misspell
|
||||
- unconvert
|
||||
- unparam
|
||||
- ineffassign
|
||||
- dupword
|
||||
- gocognit
|
||||
- gocyclo
|
||||
- funlen
|
||||
- lll
|
||||
- nestif
|
||||
- dupl
|
||||
- revive
|
||||
- unused
|
||||
- exhaustive
|
||||
- gosec
|
||||
- sloglint
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- makezero
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
errorlint:
|
||||
errorf: true
|
||||
asserts: true
|
||||
comparison: true
|
||||
gocognit:
|
||||
min-complexity: 25
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
funlen:
|
||||
lines: 90
|
||||
statements: 60
|
||||
lll:
|
||||
line-length: 120
|
||||
tab-width: 4
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: false
|
||||
check: [switch, map]
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
attr-only: true
|
||||
no-global: all
|
||||
context: scope
|
||||
static-msg: true
|
||||
no-raw-keys: true
|
||||
key-naming-case: snake
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
- path: _test\\.go
|
||||
linters: [funlen, lll, dupl, gosec]
|
||||
- path: \\.pb\\.go$
|
||||
linters: [all]
|
||||
- path: \\.connect\\.go$
|
||||
linters: [all]
|
||||
@@ -0,0 +1,24 @@
|
||||
# AGENTS.md
|
||||
|
||||
Go 1.23+ $short_purpose service.
|
||||
|
||||
## Commands
|
||||
- `task` — fmt + lint + test
|
||||
- `task build` — produce ./bin/server
|
||||
- `task ci` — full CI pipeline locally
|
||||
|
||||
## Architecture
|
||||
- `cmd/server/main.go` — entrypoint, ≤50 LOC
|
||||
- `internal/cmd/` — root command, signal wiring
|
||||
- `internal/api/` — HTTP handlers + middleware (gin)
|
||||
- `internal/domain/` — smart-constructor types, no I/O
|
||||
- `internal/store/` — DB layer (sqlc-generated, never hand-edited)
|
||||
- `internal/config/` — env-driven Config
|
||||
- `internal/obs/` — slog setup, observability
|
||||
|
||||
## Conventions
|
||||
- `slog` for all logs; never `log.*`, never `fmt.Println` in libs
|
||||
- `context.Context` first arg for every public function with I/O
|
||||
- Errors wrapped with `%w`; check with `errors.Is/As`
|
||||
- 250 pure LOC ceiling per file
|
||||
- Tests follow Given/When/Then; less mock the better
|
||||
@@ -0,0 +1,12 @@
|
||||
# $name
|
||||
|
||||
Bootstrapped with the `programming` skill's Go scaffold.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
task # fmt + lint + test
|
||||
task run # build + run server
|
||||
```
|
||||
|
||||
See `AGENTS.md` for architecture conventions.
|
||||
@@ -0,0 +1,40 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
BINARY: server
|
||||
PKG: ./cmd/server
|
||||
|
||||
tasks:
|
||||
default:
|
||||
deps: [fmt, lint, test]
|
||||
|
||||
fmt:
|
||||
cmds:
|
||||
- gofumpt -w .
|
||||
- goimports -w -local "$(go list -m)" .
|
||||
|
||||
lint:
|
||||
cmds:
|
||||
- golangci-lint run --timeout 5m ./...
|
||||
- nilaway ./... || true
|
||||
|
||||
test:
|
||||
cmds:
|
||||
- go test -race -shuffle=on -count=1 ./...
|
||||
|
||||
test-cover:
|
||||
cmds:
|
||||
- go test -race -shuffle=on -count=1 -coverprofile=coverage.out ./...
|
||||
- go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
build:
|
||||
cmds:
|
||||
- go build -trimpath -ldflags="-s -w" -o bin/{{.BINARY}} {{.PKG}}
|
||||
|
||||
run:
|
||||
deps: [build]
|
||||
cmds:
|
||||
- ./bin/{{.BINARY}}
|
||||
|
||||
ci:
|
||||
deps: [fmt, lint, test, build]
|
||||
@@ -0,0 +1,37 @@
|
||||
name: ci
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache: true
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
|
||||
go install go.uber.org/nilaway/cmd/nilaway@latest
|
||||
go install github.com/go-task/task/v3/cmd/task@latest
|
||||
|
||||
- name: Format check
|
||||
run: gofumpt -l . | (! grep .)
|
||||
|
||||
- name: Lint
|
||||
run: golangci-lint run --timeout 5m ./...
|
||||
|
||||
- name: Nilaway
|
||||
run: nilaway ./... || true
|
||||
|
||||
- name: Test
|
||||
run: go test -race -shuffle=on -count=1 ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -trimpath ./...
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package config loads typed config from env.
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string `env:"HOST" envDefault:"0.0.0.0"`
|
||||
Port int `env:"PORT" envDefault:"8080"`
|
||||
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" envDefault:"json"`
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
bin/
|
||||
coverage.out
|
||||
coverage.html
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
*.pem
|
||||
*.key
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"$module/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := cmd.Execute(ctx); err != nil {
|
||||
slog.Error("fatal", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package cmd wires the root command and subcommands.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Execute runs the root command. Wire cobra/subcommands here.
|
||||
func Execute(ctx context.Context) error {
|
||||
slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.InfoContext(ctx, "starting")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
# noqa: SIZE_OK — single self-contained checker, splitting adds import ceremony for no readability gain
|
||||
"""Check Python files for no-excuse violations.
|
||||
|
||||
The python-programmer skill enforces these rules. Run after editing.
|
||||
|
||||
Rules:
|
||||
cast-any - cast(Any, ...) / cast(typing.Any, ...) / typing.cast(Any, ...)
|
||||
type-ignore - `# type: ignore` comments (any variant)
|
||||
pyright-ignore - `# pyright: ignore` comments (any variant)
|
||||
bare-except - `except:` with no class
|
||||
silent-except - `except X: pass` or `except X: ...` (single statement)
|
||||
no-asyncio - `import asyncio` / `from asyncio import ...`
|
||||
Opt out per import line: trailing `# noqa: ANYIO_OK`
|
||||
no-pandas - `import pandas` / `from pandas import ...`
|
||||
Opt out per import line: trailing `# noqa: PANDAS_OK`
|
||||
mutable-dataclass - @dataclass without frozen=True
|
||||
Opt out: trailing `# noqa: MUTABLE_OK`
|
||||
missing-slots - @dataclass without slots=True
|
||||
Opt out: trailing `# noqa: SLOTS_OK`
|
||||
raw-dict-return - function returns bare `dict` type
|
||||
Opt out: trailing `# noqa: DICT_OK`
|
||||
missing-assert-never - match statement without assert_never in default case
|
||||
Opt out: `# noqa: MATCH_OK` on the match line
|
||||
generic-exception - raise ValueError/TypeError/RuntimeError with bare string
|
||||
Opt out: trailing `# noqa: GENERIC_ERR_OK`
|
||||
no-object - `object` used as type annotation (param, return, variable)
|
||||
Opt out: trailing `# noqa: OBJECT_OK`
|
||||
if-elif-on-variant - isinstance/enum-comparison if/elif chain (should be match/case)
|
||||
Opt out: trailing `# noqa: IF_VARIANT_OK`
|
||||
oversized-module - file exceeds 250 pure LOC (non-blank, non-comment)
|
||||
Opt out: `# noqa: SIZE_OK` in first 10 lines
|
||||
broad-except - `except Exception` / `except BaseException` (too broad)
|
||||
Opt out: trailing `# noqa: BROAD_EXCEPT_OK`
|
||||
|
||||
Usage:
|
||||
check-no-excuse-rules.py <file-or-dir>...
|
||||
|
||||
Exit codes:
|
||||
0 - no violations
|
||||
1 - one or more violations
|
||||
2 - input error (path missing, etc.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tokenize
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
EXCLUDED_DIRS = frozenset({
|
||||
".git", ".hg", ".svn", ".venv", "venv", "env", ".env",
|
||||
"__pycache__", ".tox", ".nox", "dist", "build", ".eggs",
|
||||
".ruff_cache", ".mypy_cache", ".pytest_cache", ".basedpyright",
|
||||
"node_modules",
|
||||
})
|
||||
|
||||
SUPPRESSION_RE = re.compile(r"#\s*(type|pyright)\s*:\s*ignore\b")
|
||||
ANYIO_OK_RE = re.compile(r"#\s*noqa:\s*ANYIO_OK\b")
|
||||
PANDAS_OK_RE = re.compile(r"#\s*noqa:\s*PANDAS_OK\b")
|
||||
|
||||
BANNED_IMPORTS: dict[str, tuple[str, re.Pattern[str], str]] = {
|
||||
"asyncio": (
|
||||
"no-asyncio",
|
||||
ANYIO_OK_RE,
|
||||
"import asyncio - use anyio (opt out: trailing `# noqa: ANYIO_OK`)",
|
||||
),
|
||||
"pandas": (
|
||||
"no-pandas",
|
||||
PANDAS_OK_RE,
|
||||
"import pandas - use polars (opt out: trailing `# noqa: PANDAS_OK`)",
|
||||
),
|
||||
}
|
||||
|
||||
# Opt-out patterns for new Rust-like rules
|
||||
MUTABLE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MUTABLE_OK")
|
||||
SLOTS_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SLOTS_OK")
|
||||
DICT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*DICT_OK")
|
||||
MATCH_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MATCH_OK")
|
||||
GENERIC_ERR_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*GENERIC_ERR_OK")
|
||||
OBJECT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*OBJECT_OK")
|
||||
IF_VARIANT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*IF_VARIANT_OK")
|
||||
SIZE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SIZE_OK")
|
||||
BROAD_EXCEPT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*BROAD_EXCEPT_OK")
|
||||
|
||||
PURE_LOC_LIMIT: int = 250
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Violation:
|
||||
rule: str
|
||||
file: Path
|
||||
line: int
|
||||
col: int
|
||||
message: str
|
||||
|
||||
def render(self) -> str:
|
||||
return f"{self.file}:{self.line}:{self.col}: [{self.rule}] {self.message}"
|
||||
|
||||
|
||||
def discover_files(inputs: Iterable[Path]) -> list[Path]:
|
||||
seen: set[Path] = set()
|
||||
for raw in inputs:
|
||||
path = raw.resolve()
|
||||
if not path.exists():
|
||||
print(f"check-no-excuse-rules: input does not exist: {path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if path.is_file():
|
||||
if path.suffix == ".py":
|
||||
seen.add(path)
|
||||
continue
|
||||
for child in path.rglob("*.py"):
|
||||
if any(part in EXCLUDED_DIRS for part in child.parts):
|
||||
continue
|
||||
seen.add(child)
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
def is_any_node(node: ast.AST) -> bool:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id == "Any"
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr == "Any"
|
||||
return False
|
||||
|
||||
|
||||
def is_cast_callable(node: ast.AST) -> bool:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id == "cast"
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr == "cast"
|
||||
return False
|
||||
|
||||
|
||||
def find_node_violations(tree: ast.AST, file: Path) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and is_cast_callable(node.func)
|
||||
and node.args
|
||||
and is_any_node(node.args[0])
|
||||
):
|
||||
violations.append(Violation(
|
||||
rule="cast-any",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="cast(Any, ...) - narrow with isinstance/TypeGuard or use a Protocol/TypedDict",
|
||||
))
|
||||
|
||||
if isinstance(node, ast.ExceptHandler):
|
||||
if node.type is None:
|
||||
violations.append(Violation(
|
||||
rule="bare-except",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="bare `except:` - catch the narrowest exception you mean",
|
||||
))
|
||||
|
||||
if len(node.body) != 1:
|
||||
continue
|
||||
|
||||
body = node.body[0]
|
||||
if isinstance(body, ast.Pass):
|
||||
violations.append(Violation(
|
||||
rule="silent-except",
|
||||
file=file,
|
||||
line=body.lineno,
|
||||
col=body.col_offset + 1,
|
||||
message="silent `except: pass` - log, re-raise, or actually handle the error",
|
||||
))
|
||||
elif (
|
||||
isinstance(body, ast.Expr)
|
||||
and isinstance(body.value, ast.Constant)
|
||||
and body.value.value is Ellipsis
|
||||
):
|
||||
violations.append(Violation(
|
||||
rule="silent-except",
|
||||
file=file,
|
||||
line=body.lineno,
|
||||
col=body.col_offset + 1,
|
||||
message="silent `except: ...` - log, re-raise, or actually handle the error",
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def find_import_violations(tree: ast.AST, source_lines: list[str], file: Path) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
|
||||
def line_text(lineno: int) -> str:
|
||||
index = lineno - 1
|
||||
return source_lines[index] if 0 <= index < len(source_lines) else ""
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import): # noqa: IF_VARIANT_OK — filtering walk, not closed union
|
||||
for alias in node.names:
|
||||
top = alias.name.split(".")[0]
|
||||
if top not in BANNED_IMPORTS:
|
||||
continue
|
||||
rule, opt_re, message = BANNED_IMPORTS[top]
|
||||
if opt_re.search(line_text(node.lineno)):
|
||||
continue
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=message,
|
||||
))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
top = (node.module or "").split(".")[0]
|
||||
if top not in BANNED_IMPORTS:
|
||||
continue
|
||||
rule, opt_re, message = BANNED_IMPORTS[top]
|
||||
if opt_re.search(line_text(node.lineno)):
|
||||
continue
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=message,
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def find_comment_violations(source: str, file: Path) -> list[Violation]:
|
||||
"""Use tokenize so we don't false-match `# type: ignore` inside string literals."""
|
||||
violations: list[Violation] = []
|
||||
try:
|
||||
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
||||
except tokenize.TokenError as exc:
|
||||
print(f"check-no-excuse-rules: tokenize failed for {file}: {exc}", file=sys.stderr)
|
||||
return violations
|
||||
|
||||
for tok in tokens:
|
||||
if tok.type != tokenize.COMMENT:
|
||||
continue
|
||||
match = SUPPRESSION_RE.search(tok.string)
|
||||
if not match:
|
||||
continue
|
||||
kind = match.group(1)
|
||||
rule = "type-ignore" if kind == "type" else "pyright-ignore"
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=tok.start[0],
|
||||
col=tok.start[1] + match.start() + 1,
|
||||
message=f"`# {kind}: ignore` - fix the underlying type instead",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Rust-like pattern checks
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_keyword(decorator_node: ast.Call, keyword: str) -> bool | None:
|
||||
"""Check if a decorator call has a specific keyword argument.
|
||||
|
||||
Returns True if keyword is True, False if keyword is False or absent, None if not a Call.
|
||||
"""
|
||||
for kw in decorator_node.keywords:
|
||||
if kw.arg == keyword and isinstance(kw.value, ast.Constant):
|
||||
return bool(kw.value.value)
|
||||
return False
|
||||
|
||||
|
||||
def _is_dataclass_decorator(node: ast.expr) -> tuple[bool, ast.Call | None]:
|
||||
"""Return (is_dataclass, call_node_or_None)."""
|
||||
if isinstance(node, ast.Name) and node.id == "dataclass":
|
||||
return True, None
|
||||
if isinstance(node, ast.Attribute) and node.attr == "dataclass":
|
||||
return True, None
|
||||
if isinstance(node, ast.Call):
|
||||
inner, _ = _is_dataclass_decorator(node.func)
|
||||
if inner:
|
||||
return True, node
|
||||
return False, None
|
||||
|
||||
|
||||
def find_dataclass_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check @dataclass decorators for frozen=True and slots=True."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
is_dc, call_node = _is_dataclass_decorator(dec)
|
||||
if not is_dc:
|
||||
continue
|
||||
|
||||
# Get the line of the decorator for opt-out check
|
||||
dec_line = source_lines[dec.lineno - 1] if dec.lineno <= len(source_lines) else ""
|
||||
|
||||
if call_node is not None:
|
||||
has_frozen = _has_keyword(call_node, "frozen")
|
||||
has_slots = _has_keyword(call_node, "slots")
|
||||
else:
|
||||
# bare @dataclass with no arguments
|
||||
has_frozen = False
|
||||
has_slots = False
|
||||
|
||||
if not has_frozen and not MUTABLE_OK_RE.search(dec_line):
|
||||
violations.append(Violation(
|
||||
rule="mutable-dataclass",
|
||||
file=file,
|
||||
line=dec.lineno,
|
||||
col=dec.col_offset + 1,
|
||||
message=f"class {node.name}: @dataclass without frozen=True",
|
||||
))
|
||||
|
||||
if not has_slots and not SLOTS_OK_RE.search(dec_line):
|
||||
violations.append(Violation(
|
||||
rule="missing-slots",
|
||||
file=file,
|
||||
line=dec.lineno,
|
||||
col=dec.col_offset + 1,
|
||||
message=f"class {node.name}: @dataclass without slots=True",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_dict_return_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for functions returning bare `dict` type."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
ret = node.returns
|
||||
if ret is None:
|
||||
continue
|
||||
# Check for bare `dict` return annotation
|
||||
is_bare_dict = (
|
||||
(isinstance(ret, ast.Name) and ret.id == "dict")
|
||||
or (isinstance(ret, ast.Attribute) and ret.attr == "dict")
|
||||
)
|
||||
if not is_bare_dict:
|
||||
continue
|
||||
|
||||
func_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if DICT_OK_RE.search(func_line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="raw-dict-return",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`{node.name}` returns bare dict - use TypedDict/dataclass/Pydantic model",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_match_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check match statements for assert_never in default case."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Match):
|
||||
continue
|
||||
|
||||
match_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if MATCH_OK_RE.search(match_line):
|
||||
continue
|
||||
|
||||
has_assert_never = False
|
||||
for case in node.cases:
|
||||
# Wildcard: `case _:` -> MatchAs(pattern=None, name=None)
|
||||
# `case _ as x:` -> MatchAs(pattern=MatchAs(pattern=None, name=None), name="x")
|
||||
pattern = case.pattern
|
||||
is_wildcard = (
|
||||
isinstance(pattern, ast.MatchAs)
|
||||
and (
|
||||
pattern.pattern is None
|
||||
or (
|
||||
isinstance(pattern.pattern, ast.MatchAs)
|
||||
and pattern.pattern.pattern is None
|
||||
and pattern.pattern.name is None
|
||||
)
|
||||
)
|
||||
)
|
||||
if not is_wildcard:
|
||||
continue
|
||||
# Check if body contains assert_never call
|
||||
for stmt in case.body:
|
||||
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
||||
func = stmt.value.func
|
||||
if (
|
||||
(isinstance(func, ast.Name) and func.id == "assert_never")
|
||||
or (isinstance(func, ast.Attribute) and func.attr == "assert_never")
|
||||
):
|
||||
has_assert_never = True
|
||||
break
|
||||
|
||||
if not has_assert_never:
|
||||
violations.append(Violation(
|
||||
rule="missing-assert-never",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="match without `case _: assert_never(x)` default",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_generic_exception_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for raise ValueError/TypeError/RuntimeError with bare string or f-string."""
|
||||
GENERIC_EXCEPTIONS = {"ValueError", "TypeError", "RuntimeError", "KeyError"}
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Raise) or node.exc is None:
|
||||
continue
|
||||
exc = node.exc
|
||||
# Match: raise SomeError("string literal")
|
||||
if not isinstance(exc, ast.Call):
|
||||
continue
|
||||
func = exc.func
|
||||
exc_name: str | None = None
|
||||
if isinstance(func, ast.Name) and func.id in GENERIC_EXCEPTIONS:
|
||||
exc_name = func.id
|
||||
elif isinstance(func, ast.Attribute) and func.attr in GENERIC_EXCEPTIONS:
|
||||
exc_name = func.attr
|
||||
if exc_name is None:
|
||||
continue
|
||||
# Check if all arguments are string literals or f-strings
|
||||
if not exc.args:
|
||||
continue
|
||||
all_str = all(
|
||||
(isinstance(arg, ast.Constant) and isinstance(arg.value, str))
|
||||
or isinstance(arg, ast.JoinedStr)
|
||||
for arg in exc.args
|
||||
)
|
||||
if not all_str:
|
||||
continue
|
||||
|
||||
raise_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if GENERIC_ERR_OK_RE.search(raise_line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="generic-exception",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`raise {exc_name}(\"...\")` - define a typed error class instead",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def _is_isinstance_test(node: ast.expr) -> bool:
|
||||
"""Check if node is an isinstance() call."""
|
||||
return (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "isinstance"
|
||||
)
|
||||
|
||||
|
||||
def _is_enum_comparison(node: ast.expr) -> bool:
|
||||
"""Check if node is `x == Enum.VALUE` or `x is Enum.VALUE`."""
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1:
|
||||
op = node.ops[0]
|
||||
if isinstance(op, (ast.Eq, ast.Is)):
|
||||
comparator = node.comparators[0]
|
||||
# x == Enum.VALUE (attribute access on the right)
|
||||
if isinstance(comparator, ast.Attribute):
|
||||
return True
|
||||
# Enum.VALUE == x (attribute access on the left)
|
||||
if isinstance(node.left, ast.Attribute):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_object_annotation_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for `object` used as a type annotation."""
|
||||
violations: list[Violation] = []
|
||||
|
||||
def _check_annotation(ann: ast.expr | None) -> None:
|
||||
if ann is None:
|
||||
return
|
||||
for child in ast.walk(ann):
|
||||
if isinstance(child, ast.Name) and child.id == "object":
|
||||
line = source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
|
||||
if OBJECT_OK_RE.search(line):
|
||||
return
|
||||
violations.append(Violation(
|
||||
rule="no-object",
|
||||
file=file,
|
||||
line=child.lineno,
|
||||
col=child.col_offset + 1,
|
||||
message="`object` as type annotation \u2014 use Protocol, TypeVar, or union",
|
||||
))
|
||||
|
||||
for node in ast.walk(tree):
|
||||
match node: # noqa: MATCH_OK — filtering walk, not discriminating a closed union
|
||||
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
||||
all_args = (
|
||||
node.args.args
|
||||
+ node.args.posonlyargs
|
||||
+ node.args.kwonlyargs
|
||||
)
|
||||
for arg in all_args:
|
||||
_check_annotation(arg.annotation)
|
||||
if node.args.vararg:
|
||||
_check_annotation(node.args.vararg.annotation)
|
||||
if node.args.kwarg:
|
||||
_check_annotation(node.args.kwarg.annotation)
|
||||
_check_annotation(node.returns)
|
||||
case ast.AnnAssign():
|
||||
_check_annotation(node.annotation)
|
||||
return violations
|
||||
|
||||
|
||||
def find_if_elif_variant_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for if/elif chains on isinstance or enum comparison."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
|
||||
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if IF_VARIANT_OK_RE.search(line):
|
||||
continue
|
||||
|
||||
is_variant_test = _is_isinstance_test(node.test) or _is_enum_comparison(node.test)
|
||||
if not is_variant_test:
|
||||
continue
|
||||
|
||||
# Must have at least one elif that is also a variant test
|
||||
orelse = node.orelse
|
||||
while orelse and len(orelse) == 1 and isinstance(orelse[0], ast.If):
|
||||
elif_node = orelse[0]
|
||||
if _is_isinstance_test(elif_node.test) or _is_enum_comparison(elif_node.test):
|
||||
violations.append(Violation(
|
||||
rule="if-elif-on-variant",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="isinstance/enum if/elif chain \u2014 use match/case + assert_never",
|
||||
))
|
||||
break
|
||||
orelse = elif_node.orelse
|
||||
return violations
|
||||
|
||||
|
||||
def find_broad_except_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for except Exception / except BaseException (too broad)."""
|
||||
BROAD_EXCEPTIONS = {"Exception", "BaseException"}
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ExceptHandler):
|
||||
continue
|
||||
if node.type is None:
|
||||
continue # already caught by bare-except
|
||||
|
||||
exc_name: str | None = None
|
||||
if isinstance(node.type, ast.Name) and node.type.id in BROAD_EXCEPTIONS:
|
||||
exc_name = node.type.id
|
||||
elif isinstance(node.type, ast.Attribute) and node.type.attr in BROAD_EXCEPTIONS:
|
||||
exc_name = node.type.attr
|
||||
if exc_name is None:
|
||||
continue
|
||||
|
||||
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if BROAD_EXCEPT_OK_RE.search(line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="broad-except",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`except {exc_name}` is too broad \u2014 catch the specific exception you expect",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_oversized_module_violations(
|
||||
source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check if file exceeds 250 pure LOC (non-blank, non-comment)."""
|
||||
# File-level opt-out in first 10 lines (shebang + script metadata can push it down)
|
||||
for line in source_lines[:10]:
|
||||
if SIZE_OK_RE.search(line):
|
||||
return []
|
||||
|
||||
pure_loc = sum(
|
||||
1 for line in source_lines
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
)
|
||||
if pure_loc > PURE_LOC_LIMIT:
|
||||
return [Violation(
|
||||
rule="oversized-module",
|
||||
file=file,
|
||||
line=1,
|
||||
col=1,
|
||||
message=f"{pure_loc} pure LOC (limit: {PURE_LOC_LIMIT}) \u2014 split by responsibility",
|
||||
)]
|
||||
return []
|
||||
|
||||
|
||||
def check_file(file: Path) -> list[Violation]:
|
||||
source = file.read_text(encoding="utf-8")
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(file))
|
||||
except SyntaxError as exc:
|
||||
return [Violation(
|
||||
rule="syntax-error",
|
||||
file=file,
|
||||
line=exc.lineno or 1,
|
||||
col=exc.offset or 1,
|
||||
message=f"SyntaxError: {exc.msg}",
|
||||
)]
|
||||
|
||||
source_lines = source.splitlines()
|
||||
return [
|
||||
*find_node_violations(tree, file),
|
||||
*find_import_violations(tree, source_lines, file),
|
||||
*find_comment_violations(source, file),
|
||||
*find_dataclass_violations(tree, source_lines, file),
|
||||
*find_dict_return_violations(tree, source_lines, file),
|
||||
*find_match_violations(tree, source_lines, file),
|
||||
*find_generic_exception_violations(tree, source_lines, file),
|
||||
*find_object_annotation_violations(tree, source_lines, file),
|
||||
*find_if_elif_variant_violations(tree, source_lines, file),
|
||||
*find_oversized_module_violations(source_lines, file),
|
||||
*find_broad_except_violations(tree, source_lines, file),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: check-no-excuse-rules.py <file-or-dir>...", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = discover_files(Path(arg) for arg in sys.argv[1:])
|
||||
if not files:
|
||||
print("check-no-excuse-rules: no .py files found", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
violations: list[Violation] = []
|
||||
for file in files:
|
||||
violations.extend(check_file(file))
|
||||
|
||||
if not violations:
|
||||
print(f"no violations in {len(files)} file(s)")
|
||||
return 0
|
||||
|
||||
for violation in violations:
|
||||
print(violation.render(), file=sys.stderr)
|
||||
print(
|
||||
f"\n{len(violations)} violation(s) in {len(files)} file(s)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-project.py myproject
|
||||
# uv run new-project.py myproject --path ./workspace
|
||||
# uv run new-project.py myproject --lib # library (publishable)
|
||||
# ──────────────────
|
||||
|
||||
"""Scaffold a new Python project with ultra-strict config from pyproject-strict.md.
|
||||
|
||||
Creates via `uv init`, then injects basedpyright + ruff ALL + pytest config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
# ── Strict tool config (from pyproject-strict.md) ──
|
||||
|
||||
TOOL_CONFIG = '''
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.21",
|
||||
"ruff>=0.8",
|
||||
"pytest>=8",
|
||||
"pytest-cov>=5",
|
||||
]
|
||||
|
||||
[tool.basedpyright]
|
||||
typeCheckingMode = "all"
|
||||
pythonVersion = "3.13"
|
||||
reportMissingTypeStubs = false
|
||||
reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportUnknownLambdaType = false
|
||||
reportUnknownParameterType = false
|
||||
reportMissingParameterType = false
|
||||
reportUnnecessaryIsInstance = false
|
||||
reportUnusedCallResult = false
|
||||
reportImplicitOverride = false
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # trailing comma (conflicts with formatter)
|
||||
"ISC001", # single-line string concat (conflicts with formatter)
|
||||
"D1", # undocumented-public-* (too noisy early on)
|
||||
"ANN101", # deprecated: self annotation
|
||||
"ANN102", # deprecated: cls annotation
|
||||
"S101", # assert used (pytest needs it)
|
||||
"PLR2004", # magic-value-comparison (test data)
|
||||
"FBT", # boolean-trap (too strict for CLIs)
|
||||
"TD", # flake8-todos (noisy)
|
||||
"FIX", # fixme (noisy)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101", "PLR2004", "SLF001", "D", "ARG", "ANN"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra --strict-markers --strict-config"
|
||||
'''
|
||||
|
||||
GITIGNORE = """\
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.venv/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.basedpyright/
|
||||
.ruff_cache/
|
||||
"""
|
||||
|
||||
|
||||
def main(
|
||||
name: str = typer.Argument(help="Project name"),
|
||||
path: Path = typer.Option(Path("."), "--path", "-p", help="Parent directory"),
|
||||
lib: bool = typer.Option(False, "--lib", help="Create as publishable library (uv init --lib)"),
|
||||
) -> None:
|
||||
"""Create a new Python project with ultra-strict config."""
|
||||
project_dir = path / name
|
||||
|
||||
if project_dir.exists():
|
||||
rprint(f"[red]Error:[/red] {project_dir} already exists")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Run uv init
|
||||
cmd = ["uv", "init", "--lib" if lib else "--app", str(project_dir)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
rprint(f"[red]uv init failed:[/red] {result.stderr}")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Read existing pyproject.toml
|
||||
pyproject_path = project_dir / "pyproject.toml"
|
||||
content = pyproject_path.read_text()
|
||||
|
||||
# Remove the default [dependency-groups] if uv init created one
|
||||
# (we'll replace it with our strict version)
|
||||
lines = content.splitlines(keepends=True)
|
||||
filtered: list[str] = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("[dependency-groups]"):
|
||||
skip = True
|
||||
continue
|
||||
if skip and line.strip().startswith("["):
|
||||
skip = False
|
||||
if not skip:
|
||||
filtered.append(line)
|
||||
|
||||
content = "".join(filtered).rstrip("\n") + "\n"
|
||||
|
||||
# Append strict tool config
|
||||
content += TOOL_CONFIG
|
||||
|
||||
pyproject_path.write_text(content)
|
||||
|
||||
# Add dev dependencies
|
||||
subprocess.run(
|
||||
["uv", "add", "--dev", "basedpyright", "ruff", "pytest", "pytest-cov"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Create tests directory
|
||||
tests_dir = project_dir / "tests"
|
||||
tests_dir.mkdir(exist_ok=True)
|
||||
(tests_dir / "__init__.py").touch()
|
||||
|
||||
# Overwrite .gitignore
|
||||
(project_dir / ".gitignore").write_text(GITIGNORE)
|
||||
|
||||
# Create py.typed marker for libraries
|
||||
if lib:
|
||||
src_dir = project_dir / "src" / name.replace("-", "_")
|
||||
if src_dir.exists():
|
||||
(src_dir / "py.typed").touch()
|
||||
|
||||
rprint(f"[green]✓[/green] Created: [bold]{project_dir}[/bold]")
|
||||
rprint(f" cd {name} && uv sync && uv run basedpyright . && uv run ruff check .")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-script.py my_tool
|
||||
# uv run new-script.py my_tool --output ./scripts/my_tool.py
|
||||
# uv run new-script.py my_tool --deps 'httpx2[http2,brotli,zstd]' --deps rich --deps polars
|
||||
# uv run new-script.py my_tool --py 3.13
|
||||
# ──────────────────
|
||||
|
||||
"""Generate a PEP 723 Python script with all boilerplate pre-filled.
|
||||
|
||||
Creates a new .py file with:
|
||||
- uv shebang
|
||||
- PEP 723 inline metadata (requires-python + dependencies)
|
||||
- Mandatory "How to run" comment block
|
||||
- from __future__ import annotations
|
||||
- main() + if __name__ guard
|
||||
|
||||
By default writes to a temp directory and prints the path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
TEMPLATE = '''\
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">={python_version}"
|
||||
# dependencies = [
|
||||
{deps_block}# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run directly (no venv, no pip install needed):
|
||||
# uv run {filename} {args_hint}
|
||||
# 3. Or make executable and run:
|
||||
# chmod +x {filename} && ./{filename}
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO: implement."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def main(
|
||||
name: str = typer.Argument(help="Script name (without .py extension)"),
|
||||
output: Path | None = typer.Option(None, "--output", "-o", help="Output path. Default: OS temp directory."),
|
||||
deps: list[str] = typer.Option([], "--deps", "-d", help="Dependencies to include (repeat --deps for each)."),
|
||||
py: str = typer.Option("3.13", "--py", help="Minimum Python version."),
|
||||
) -> None:
|
||||
"""Generate a new PEP 723 script with all boilerplate pre-filled."""
|
||||
filename = f"{name}.py" if not name.endswith(".py") else name
|
||||
stem = filename.removesuffix(".py")
|
||||
|
||||
if output is not None:
|
||||
dest = Path(output)
|
||||
else:
|
||||
tmp_dir = Path(tempfile.gettempdir()) / "uv-scripts"
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
dest = tmp_dir / filename
|
||||
|
||||
dep_list = deps or []
|
||||
if dep_list:
|
||||
deps_block = "".join(f'# "{d}",\n' for d in dep_list)
|
||||
else:
|
||||
deps_block = '# # add deps here, e.g.: "httpx2[http2,brotli,zstd]"\n'
|
||||
|
||||
content = TEMPLATE.format(
|
||||
python_version=py,
|
||||
deps_block=deps_block,
|
||||
filename=filename,
|
||||
args_hint="",
|
||||
)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(content)
|
||||
|
||||
# Make executable on Unix
|
||||
if sys.platform != "win32":
|
||||
st = dest.stat()
|
||||
dest.chmod(st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
rprint(f"[green]✓[/green] Created: [bold]{dest}[/bold]")
|
||||
rprint(f" Run: [cyan]uv run {dest}[/cyan]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
#
|
||||
# How to run:
|
||||
# uv run --script check-no-excuse-rules.py src/lib.rs src/main.rs
|
||||
# uv run --script check-no-excuse-rules.py src/ # recursively finds .rs files
|
||||
# uv run --script check-no-excuse-rules.py . # entire tree
|
||||
#
|
||||
# No-excuse rule checker for Rust files — Python rewrite of check-no-excuse-rules.sh.
|
||||
# Only rules enforceable via pure text matching live here.
|
||||
# Everything semantic is on clippy + miri + nextest.
|
||||
#
|
||||
# Rules:
|
||||
# unwrap .unwrap() outside tests without // SAFE-UNWRAP:
|
||||
# expect .expect() outside tests without // SAFE-EXPECT:
|
||||
# placeholder-macro todo!/unimplemented!/unreachable!/unreachable_unchecked! in committed code
|
||||
# box-dyn-error Box<dyn Error> in non-test code
|
||||
# lib-panic panic!() in library code
|
||||
# unsafe-no-safety unsafe { without // SAFETY: in preceding 5 lines
|
||||
# unjustified-clippy-allow #[allow(clippy::...)] without // CLIPPY-ALLOW:
|
||||
# narrowing-as-cast possible narrowing 'as' cast
|
||||
#
|
||||
# Opt-out: place the appropriate comment on the previous line:
|
||||
# // SAFE-UNWRAP: <reason>
|
||||
# // SAFE-EXPECT: <reason>
|
||||
# // SAFETY: <reason> (for unsafe blocks, within 5 lines above)
|
||||
# // CLIPPY-ALLOW: <reason>
|
||||
#
|
||||
# Test paths (exempt from unwrap/expect/placeholder/box-dyn-error/lib-panic):
|
||||
# tests/, benches/, examples/, build.rs, *_test.rs, #[cfg(test)] regions
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patterns (compiled once)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RE_UNWRAP = re.compile(r"\.unwrap\(\)")
|
||||
RE_EXPECT = re.compile(r"\.expect\(")
|
||||
RE_PLACEHOLDER = re.compile(r"\b(todo!|unimplemented!|unreachable!|unreachable_unchecked!)")
|
||||
RE_BOX_DYN_ERROR = re.compile(r"Box<dyn\s+Error")
|
||||
RE_PANIC = re.compile(r"\bpanic!\(")
|
||||
RE_UNSAFE_BLOCK = re.compile(r"\bunsafe\s*\{")
|
||||
RE_CLIPPY_ALLOW = re.compile(r"#\[allow\(clippy::")
|
||||
RE_CFG_TEST = re.compile(r"#\[cfg\(test\)\]")
|
||||
RE_SAFE_UNWRAP = re.compile(r"//\s*SAFE-UNWRAP:")
|
||||
RE_SAFE_EXPECT = re.compile(r"//\s*SAFE-EXPECT:")
|
||||
RE_SAFETY = re.compile(r"//\s*SAFETY:")
|
||||
RE_CLIPPY_ALLOW_JUST = re.compile(r"//\s*CLIPPY-ALLOW:")
|
||||
|
||||
# Narrowing cast: (wider) as (narrower)
|
||||
# Wider types that lose bits when cast to narrower targets.
|
||||
# No leading \b — must match e.g. `999u64 as u32` where a digit precedes the type.
|
||||
_WIDER = r"(?:u16|u32|u64|u128|usize|i16|i32|i64|i128|isize)"
|
||||
_NARROWER = r"(?:u8|u16|u32|i8|i16|i32)"
|
||||
RE_NARROWING_CAST = re.compile(
|
||||
rf"{_WIDER}\s+as\s+{_NARROWER}"
|
||||
)
|
||||
|
||||
# Test-path fragments.
|
||||
_TEST_PATH_PARTS = {"tests", "benches", "examples"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
violations = 0
|
||||
|
||||
|
||||
def report(file: str, line: int, rule: str, detail: str) -> None:
|
||||
"""Emit a GitHub-Actions-compatible error annotation to stderr."""
|
||||
global violations
|
||||
print(f"::error file={file},line={line}::[{rule}] {detail}", file=sys.stderr)
|
||||
violations += 1
|
||||
|
||||
|
||||
def is_test_path(path: Path) -> bool:
|
||||
"""Return True if *path* is in a test/bench/example directory or is a test file."""
|
||||
parts = path.parts
|
||||
for part in parts:
|
||||
if part in _TEST_PATH_PARTS:
|
||||
return True
|
||||
if path.name == "build.rs":
|
||||
return True
|
||||
if path.name.endswith("_test.rs"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_lib_path(file: Path) -> bool:
|
||||
"""Heuristic: is this file library code (not main.rs, not src/bin/*)."""
|
||||
parts = path_parts_str(file)
|
||||
# Must live under src/
|
||||
if "src" not in parts:
|
||||
return False
|
||||
if file.name == "main.rs":
|
||||
return False
|
||||
# src/bin/* is binary code
|
||||
try:
|
||||
src_idx = parts.index("src")
|
||||
if src_idx + 1 < len(parts) and parts[src_idx + 1] == "bin":
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def path_parts_str(p: Path) -> list[str]:
|
||||
return list(p.parts)
|
||||
|
||||
|
||||
def strip_line_comment(line: str) -> str:
|
||||
"""Return the portion of *line* before any ``//`` line comment.
|
||||
|
||||
This is a crude heuristic — it does not handle ``//`` inside string
|
||||
literals, but matches the behaviour of the bash version.
|
||||
"""
|
||||
idx = line.find("//")
|
||||
if idx == -1:
|
||||
return line
|
||||
return line[:idx]
|
||||
|
||||
|
||||
def collect_rs_files(args: list[str]) -> list[Path]:
|
||||
"""Expand CLI arguments: files are kept as-is, directories are walked."""
|
||||
result: list[Path] = []
|
||||
for arg in args:
|
||||
p = Path(arg)
|
||||
if p.is_file():
|
||||
if p.suffix == ".rs":
|
||||
result.append(p)
|
||||
elif p.is_dir():
|
||||
result.extend(sorted(p.rglob("*.rs")))
|
||||
# Ignore non-existent / non-.rs
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main checker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_file(file: Path) -> None:
|
||||
in_test_file = is_test_path(file)
|
||||
in_cfg_test = False
|
||||
cfg_test_brace_depth = 0
|
||||
|
||||
try:
|
||||
lines = file.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
print(f"warning: cannot read {file}: {exc}", file=sys.stderr)
|
||||
return
|
||||
|
||||
for line_no_0, raw_line in enumerate(lines):
|
||||
line_no = line_no_0 + 1 # 1-indexed
|
||||
|
||||
# --- #[cfg(test)] region tracker ---
|
||||
if RE_CFG_TEST.search(raw_line):
|
||||
in_cfg_test = True
|
||||
cfg_test_brace_depth = 0
|
||||
|
||||
if in_cfg_test:
|
||||
opens = raw_line.count("{")
|
||||
closes = raw_line.count("}")
|
||||
cfg_test_brace_depth += opens - closes
|
||||
if cfg_test_brace_depth <= 0 and not RE_CFG_TEST.search(raw_line):
|
||||
in_cfg_test = False
|
||||
|
||||
exempt = in_test_file or in_cfg_test
|
||||
code_only = strip_line_comment(raw_line)
|
||||
|
||||
if not exempt:
|
||||
# .unwrap()
|
||||
if RE_UNWRAP.search(code_only):
|
||||
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
|
||||
if not RE_SAFE_UNWRAP.search(prev):
|
||||
report(
|
||||
str(file), line_no, "unwrap",
|
||||
".unwrap() outside tests - use ? / ok_or / pattern match "
|
||||
"or annotate previous line with // SAFE-UNWRAP: <reason>",
|
||||
)
|
||||
|
||||
# .expect(...)
|
||||
if RE_EXPECT.search(code_only):
|
||||
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
|
||||
if not RE_SAFE_EXPECT.search(prev):
|
||||
report(
|
||||
str(file), line_no, "expect",
|
||||
".expect() outside tests - use ? or annotate previous "
|
||||
"line with // SAFE-EXPECT: <reason>",
|
||||
)
|
||||
|
||||
# todo!/unimplemented!/unreachable!/unreachable_unchecked!
|
||||
if RE_PLACEHOLDER.search(code_only):
|
||||
report(
|
||||
str(file), line_no, "placeholder-macro",
|
||||
"todo!/unimplemented!/unreachable! in committed code",
|
||||
)
|
||||
|
||||
# Box<dyn Error>
|
||||
if RE_BOX_DYN_ERROR.search(code_only):
|
||||
report(
|
||||
str(file), line_no, "box-dyn-error",
|
||||
"Box<dyn Error> in non-test code - use anyhow::Error (apps) "
|
||||
"or thiserror enum (libs)",
|
||||
)
|
||||
|
||||
# panic!() in library code
|
||||
if is_lib_path(file) and RE_PANIC.search(code_only):
|
||||
report(
|
||||
str(file), line_no, "lib-panic",
|
||||
"panic!() in library code - return Result",
|
||||
)
|
||||
|
||||
# unsafe { without // SAFETY: — always enforced, even in tests
|
||||
if RE_UNSAFE_BLOCK.search(code_only):
|
||||
start = max(0, line_no_0 - 5)
|
||||
window = "\n".join(lines[start : line_no_0 + 1])
|
||||
if not RE_SAFETY.search(window):
|
||||
report(
|
||||
str(file), line_no, "unsafe-no-safety-comment",
|
||||
"unsafe block without // SAFETY: comment in preceding 5 lines",
|
||||
)
|
||||
|
||||
# #[allow(clippy::...)] without // CLIPPY-ALLOW: — always enforced
|
||||
if RE_CLIPPY_ALLOW.search(code_only):
|
||||
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
|
||||
if not RE_CLIPPY_ALLOW_JUST.search(prev):
|
||||
report(
|
||||
str(file), line_no, "unjustified-clippy-allow",
|
||||
"#[allow(clippy::...)] without // CLIPPY-ALLOW: <reason> on "
|
||||
"previous line",
|
||||
)
|
||||
|
||||
# Narrowing numeric `as` casts
|
||||
if RE_NARROWING_CAST.search(code_only):
|
||||
report(
|
||||
str(file), line_no, "narrowing-as-cast",
|
||||
"possible narrowing 'as' cast - use TryFrom / try_into() for "
|
||||
"fallible conversion",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
global violations
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <file.rs|dir> [file.rs|dir ...]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
files = collect_rs_files(sys.argv[1:])
|
||||
if not files:
|
||||
print("warning: no .rs files found in the given arguments", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for f in files:
|
||||
check_file(f)
|
||||
|
||||
if violations > 0:
|
||||
print("", file=sys.stderr)
|
||||
print(
|
||||
f"rust-programmer: {violations} violation(s). Fix before declaring work done.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("", file=sys.stderr)
|
||||
print("Then run the full toolchain gate:", file=sys.stderr)
|
||||
print(" cargo +stable fmt --all -- --check", file=sys.stderr)
|
||||
print(
|
||||
" cargo +stable clippy --all-targets --all-features -- -D warnings",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" cargo nextest run --all-targets --all-features", file=sys.stderr)
|
||||
print(
|
||||
" cargo +nightly miri nextest run --all-features # if unsafe touched",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" cargo machete", file=sys.stderr)
|
||||
print(" cargo deny check", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"rust-programmer: no-excuse rules passed for {len(files)} file(s).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# No-excuse rule checker for Rust files.
|
||||
# Mirrors the philosophy of python-programmer / typescript-programmer scripts:
|
||||
# only rules that can be enforced via pure text matching live here.
|
||||
# Everything semantic is on clippy + miri + nextest.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file.rs> [file.rs ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
violations=0
|
||||
report() {
|
||||
local file="$1"
|
||||
local line="$2"
|
||||
local rule="$3"
|
||||
local detail="$4"
|
||||
echo "::error file=${file},line=${line}::[${rule}] ${detail}" >&2
|
||||
violations=$((violations + 1))
|
||||
}
|
||||
|
||||
is_test_path() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
*/tests/*|*/benches/*|*/examples/*|*/build.rs|*_test.rs|tests/*|benches/*|examples/*) return 0 ;;
|
||||
esac
|
||||
# In-file #[cfg(test)] modules are handled per-line below.
|
||||
return 1
|
||||
}
|
||||
|
||||
for file in "$@"; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.rs) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
if is_test_path "$file"; then
|
||||
# Test files are exempt from unwrap/expect/todo rules.
|
||||
# Still enforce unsafe-comment, allow-comment, panic-in-lib rules below
|
||||
# by setting a marker - keeping the loop unified.
|
||||
in_test_file=1
|
||||
else
|
||||
in_test_file=0
|
||||
fi
|
||||
|
||||
# Track #[cfg(test)] regions for per-line exemptions.
|
||||
in_cfg_test=0
|
||||
cfg_test_brace_depth=0
|
||||
line_no=0
|
||||
|
||||
while IFS= read -r raw_line || [ -n "$raw_line" ]; do
|
||||
line_no=$((line_no + 1))
|
||||
line="$raw_line"
|
||||
|
||||
# Crude #[cfg(test)] region tracker: when we see #[cfg(test)] on a
|
||||
# line followed by a mod with `{`, count braces until depth returns
|
||||
# to zero. This is approximate but matches typical formatting.
|
||||
if [[ "$line" =~ \#\[cfg\(test\)\] ]]; then
|
||||
in_cfg_test=1
|
||||
cfg_test_brace_depth=0
|
||||
fi
|
||||
if [ "$in_cfg_test" -eq 1 ]; then
|
||||
opens=$(printf '%s' "$line" | tr -cd '{' | wc -c)
|
||||
closes=$(printf '%s' "$line" | tr -cd '}' | wc -c)
|
||||
cfg_test_brace_depth=$((cfg_test_brace_depth + opens - closes))
|
||||
if [ "$cfg_test_brace_depth" -le 0 ] && [[ ! "$line" =~ \#\[cfg\(test\)\] ]]; then
|
||||
in_cfg_test=0
|
||||
fi
|
||||
fi
|
||||
|
||||
exempt=0
|
||||
[ "$in_test_file" -eq 1 ] && exempt=1
|
||||
[ "$in_cfg_test" -eq 1 ] && exempt=1
|
||||
|
||||
# Strip line comments before pattern checks - so doc comments and
|
||||
# explanatory prose do not trip the regexes.
|
||||
code_only="${line%%//*}"
|
||||
|
||||
if [ "$exempt" -eq 0 ]; then
|
||||
# .unwrap()
|
||||
if [[ "$code_only" =~ \.unwrap\(\) ]]; then
|
||||
# Allow if previous line had // SAFE-UNWRAP: comment
|
||||
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
|
||||
if [[ ! "$prev_line" =~ //[[:space:]]*SAFE-UNWRAP: ]]; then
|
||||
report "$file" "$line_no" "unwrap" ".unwrap() outside tests - use ? / ok_or / pattern match or annotate previous line with // SAFE-UNWRAP: <reason>"
|
||||
fi
|
||||
fi
|
||||
|
||||
# .expect("...")
|
||||
if [[ "$code_only" =~ \.expect\( ]]; then
|
||||
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
|
||||
if [[ ! "$prev_line" =~ //[[:space:]]*SAFE-EXPECT: ]]; then
|
||||
report "$file" "$line_no" "expect" ".expect() outside tests - use ? or annotate previous line with // SAFE-EXPECT: <reason>"
|
||||
fi
|
||||
fi
|
||||
|
||||
# todo!() / unimplemented!() / unreachable!()
|
||||
if [[ "$code_only" =~ (todo!|unimplemented!|unreachable!|unreachable_unchecked!) ]]; then
|
||||
report "$file" "$line_no" "placeholder-macro" "todo!/unimplemented!/unreachable! in committed code"
|
||||
fi
|
||||
|
||||
# Box<dyn Error
|
||||
if [[ "$code_only" =~ Box\<dyn[[:space:]]+Error ]]; then
|
||||
report "$file" "$line_no" "box-dyn-error" "Box<dyn Error> in non-test code - use anyhow::Error (apps) or thiserror enum (libs)"
|
||||
fi
|
||||
|
||||
# panic!( in lib
|
||||
if [[ "$file" == */src/lib.rs || "$file" == */src/*/mod.rs || ( "$file" == */src/*.rs && "$file" != */src/main.rs && "$file" != */src/bin/* ) ]]; then
|
||||
if [[ "$code_only" =~ panic!\( ]]; then
|
||||
report "$file" "$line_no" "lib-panic" "panic!() in library code - return Result"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# unsafe { without preceding // SAFETY: in the last 5 lines (always enforced)
|
||||
if [[ "$code_only" =~ unsafe[[:space:]]*\{ ]]; then
|
||||
start=$((line_no > 5 ? line_no - 5 : 1))
|
||||
window=$(sed -n "${start},${line_no}p" "$file" 2>/dev/null || true)
|
||||
if [[ ! "$window" =~ //[[:space:]]*SAFETY: ]]; then
|
||||
report "$file" "$line_no" "unsafe-no-safety-comment" "unsafe block without // SAFETY: comment in preceding 5 lines"
|
||||
fi
|
||||
fi
|
||||
|
||||
# #[allow(clippy::...)] without preceding // CLIPPY-ALLOW: justification
|
||||
if [[ "$code_only" =~ \#\[allow\(clippy:: ]]; then
|
||||
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
|
||||
if [[ ! "$prev_line" =~ //[[:space:]]*CLIPPY-ALLOW: ]]; then
|
||||
report "$file" "$line_no" "unjustified-clippy-allow" "#[allow(clippy::...)] without // CLIPPY-ALLOW: <reason> on previous line"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Narrowing numeric `as` casts - heuristic flag for human review.
|
||||
# Catches the common shapes; precise type analysis belongs to clippy::cast_possible_truncation.
|
||||
if [[ "$code_only" =~ as[[:space:]]+(u8|u16|u32|i8|i16|i32) ]] && \
|
||||
[[ "$code_only" =~ (u16|u32|u64|u128|usize|i16|i32|i64|i128|isize)[[:space:]]+as[[:space:]]+(u8|u16|u32|i8|i16|i32) ]]; then
|
||||
report "$file" "$line_no" "narrowing-as-cast" "possible narrowing 'as' cast - use TryFrom / try_into() for fallible conversion"
|
||||
fi
|
||||
done < "$file"
|
||||
done
|
||||
|
||||
if [ "$violations" -gt 0 ]; then
|
||||
echo "" >&2
|
||||
echo "rust-programmer: ${violations} violation(s). Fix before declaring work done." >&2
|
||||
echo "" >&2
|
||||
echo "Then run the full toolchain gate:" >&2
|
||||
echo " cargo +stable fmt --all -- --check" >&2
|
||||
echo " cargo +stable clippy --all-targets --all-features -- -D warnings" >&2
|
||||
echo " cargo nextest run --all-targets --all-features" >&2
|
||||
echo " cargo +nightly miri nextest run --all-features # if unsafe touched" >&2
|
||||
echo " cargo machete" >&2
|
||||
echo " cargo deny check" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "rust-programmer: no-excuse rules passed for $# file(s)."
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-project.py myproject
|
||||
# uv run new-project.py myproject --path ./workspace
|
||||
# ──────────────────
|
||||
#
|
||||
# Creates a new Rust project with strict lints, deny.toml, rustfmt.toml,
|
||||
# rust-toolchain.toml, and .cargo/config.toml pre-configured.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(stderr=True)
|
||||
|
||||
# ── Embedded config contents ─────────────────────────────────────────────
|
||||
|
||||
RUST_TOOLCHAIN_TOML = """\
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy", "rust-src"]
|
||||
profile = "default"
|
||||
"""
|
||||
|
||||
CARGO_TOML_LINTS = """
|
||||
[lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
missing_docs = "warn"
|
||||
missing_debug_implementations = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
non_ascii_idents = "deny"
|
||||
trivial_numeric_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
single_use_lifetimes = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "deny", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
cargo = { level = "warn", priority = -1 }
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
multiple_unsafe_ops_per_block = "deny"
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
dbg_macro = "deny"
|
||||
print_stdout = "warn"
|
||||
print_stderr = "warn"
|
||||
module_name_repetitions = { level = "allow" }
|
||||
must_use_candidate = { level = "allow" }
|
||||
missing_errors_doc = { level = "allow" }
|
||||
missing_panics_doc = { level = "allow" }
|
||||
"""
|
||||
|
||||
CARGO_CONFIG_TOML = """\
|
||||
[build]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
rustflags = []
|
||||
"""
|
||||
|
||||
DENY_TOML = """\
|
||||
[advisories]
|
||||
vulnerability = "deny"
|
||||
unmaintained = "warn"
|
||||
yanked = "deny"
|
||||
|
||||
[licenses]
|
||||
unlicensed = "deny"
|
||||
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib"]
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "deny"
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
"""
|
||||
|
||||
RUSTFMT_TOML = """\
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
"""
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app = typer.Typer(add_completion=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
name: str = typer.Argument(help="Name of the new Rust project"),
|
||||
path: Path = typer.Option(
|
||||
Path.cwd(),
|
||||
"--path",
|
||||
"-p",
|
||||
help="Parent directory where the project folder is created",
|
||||
),
|
||||
) -> None:
|
||||
"""Scaffold a new Rust project with strict lints and tooling configs."""
|
||||
project_dir = path / name
|
||||
|
||||
# ── cargo init ───────────────────────────────────────────────────
|
||||
console.print(f"[bold green]Creating[/] project [cyan]{name}[/] at [dim]{project_dir}[/]")
|
||||
try:
|
||||
subprocess.run(
|
||||
["cargo", "init", str(project_dir), "--name", name],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
console.print("[bold red]Error:[/] cargo not found. Install Rust via https://rustup.rs")
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
console.print(f"[bold red]cargo init failed:[/]\n{exc.stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
# ── rust-toolchain.toml ──────────────────────────────────────────
|
||||
(project_dir / "rust-toolchain.toml").write_text(RUST_TOOLCHAIN_TOML)
|
||||
console.print(" [dim]wrote[/] rust-toolchain.toml")
|
||||
|
||||
# ── Append [lints] to Cargo.toml ─────────────────────────────────
|
||||
cargo_toml = project_dir / "Cargo.toml"
|
||||
with cargo_toml.open("a") as f:
|
||||
f.write(CARGO_TOML_LINTS)
|
||||
console.print(" [dim]appended[/] [lints] to Cargo.toml")
|
||||
|
||||
# ── .cargo/config.toml ───────────────────────────────────────────
|
||||
cargo_config_dir = project_dir / ".cargo"
|
||||
cargo_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cargo_config_dir / "config.toml").write_text(CARGO_CONFIG_TOML)
|
||||
console.print(" [dim]wrote[/] .cargo/config.toml")
|
||||
|
||||
# ── deny.toml ────────────────────────────────────────────────────
|
||||
(project_dir / "deny.toml").write_text(DENY_TOML)
|
||||
console.print(" [dim]wrote[/] deny.toml")
|
||||
|
||||
# ── rustfmt.toml ─────────────────────────────────────────────────
|
||||
(project_dir / "rustfmt.toml").write_text(RUSTFMT_TOML)
|
||||
console.print(" [dim]wrote[/] rustfmt.toml")
|
||||
|
||||
console.print(f"\n[bold green]Done![/] cd {project_dir} && cargo check")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Check TypeScript files for no-excuse violations.
|
||||
*
|
||||
* Rules:
|
||||
* no-any-assertion - `as any`
|
||||
* no-unknown-assertion - `as unknown`
|
||||
* no-ts-ignore - `@ts-ignore` comments
|
||||
* no-ts-expect-error - `@ts-expect-error` comments
|
||||
* no-enum - `enum` declarations
|
||||
* no-non-null-assertion - `x!` postfix operator
|
||||
* no-throw-literal - `throw "string"` / `throw 123`
|
||||
* no-mutable-export - `export let` / `export var`
|
||||
* no-any-annotation - `: any` in annotations (opt out: `// no-excuse-ok: any`)
|
||||
* no-explicit-any-return - `(): any` return types (opt out: `// no-excuse-ok: any`)
|
||||
* empty-catch - `catch { }` or `catch (e) { }` with empty body
|
||||
* catch-without-narrowing - catch block that uses error without instanceof narrowing
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/check-no-excuse-rules.ts <file-or-dir>...
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - no violations
|
||||
* 1 - violations found
|
||||
* 2 - input error
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import process from "node:process"
|
||||
import ts from "typescript"
|
||||
|
||||
type RuleId =
|
||||
| "no-any-assertion"
|
||||
| "no-unknown-assertion"
|
||||
| "no-ts-ignore"
|
||||
| "no-ts-expect-error"
|
||||
| "no-enum"
|
||||
| "no-non-null-assertion"
|
||||
| "no-throw-literal"
|
||||
| "no-mutable-export"
|
||||
| "no-any-annotation"
|
||||
| "no-explicit-any-return"
|
||||
| "empty-catch"
|
||||
| "catch-without-narrowing"
|
||||
|
||||
type Violation = {
|
||||
readonly ruleId: RuleId
|
||||
readonly filePath: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
const INCLUDED_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"])
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
".git", ".next", ".nuxt", ".turbo", ".yarn",
|
||||
"coverage", "dist", "build", "node_modules",
|
||||
])
|
||||
|
||||
const OPT_OUT_RE = /\/\/\s*no-excuse-ok:\s*any/
|
||||
const CATCH_OK_RE = /\/\/\s*no-excuse-ok:\s*catch/
|
||||
|
||||
function isIncludedFile(filePath: string): boolean {
|
||||
return INCLUDED_EXTENSIONS.has(path.extname(filePath).toLowerCase())
|
||||
}
|
||||
|
||||
function isDeclarationFile(filePath: string): boolean {
|
||||
return filePath.endsWith(".d.ts") || filePath.endsWith(".d.mts") || filePath.endsWith(".d.cts")
|
||||
}
|
||||
|
||||
function discoverFiles(inputs: string[]): string[] {
|
||||
const files: string[] = []
|
||||
for (const input of inputs) {
|
||||
const resolved = path.resolve(input)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
console.error(`Path does not exist: ${resolved}`)
|
||||
process.exit(2)
|
||||
}
|
||||
if (fs.statSync(resolved).isFile()) {
|
||||
if (isIncludedFile(resolved) && !isDeclarationFile(resolved)) files.push(resolved)
|
||||
continue
|
||||
}
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!IGNORED_DIRECTORIES.has(entry.name)) walk(path.join(dir, entry.name))
|
||||
} else if (isIncludedFile(entry.name) && !isDeclarationFile(entry.name)) {
|
||||
files.push(path.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(resolved)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function getLineText(sourceFile: ts.SourceFile, line: number): string {
|
||||
const lineStarts = sourceFile.getLineStarts()
|
||||
const start = lineStarts[line]
|
||||
const end = line + 1 < lineStarts.length ? lineStarts[line + 1] : sourceFile.getEnd()
|
||||
return sourceFile.text.slice(start, end)
|
||||
}
|
||||
|
||||
function analyzeFile(filePath: string): Violation[] {
|
||||
const source = fs.readFileSync(filePath, "utf-8")
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true)
|
||||
const violations: Violation[] = []
|
||||
|
||||
function pos(node: ts.Node): { line: number; column: number } {
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
return { line: line + 1, column: character + 1 }
|
||||
}
|
||||
|
||||
function lineHasOptOut(node: ts.Node): boolean {
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
return OPT_OUT_RE.test(getLineText(sourceFile, line))
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
// ── as any / as unknown ──
|
||||
if (ts.isAsExpression(node)) {
|
||||
const typeText = node.type.getText(sourceFile)
|
||||
if (typeText === "any") {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-any-assertion", filePath, ...p, message: "`as any` — narrow with type guards or redesign the types" })
|
||||
}
|
||||
if (typeText === "unknown") {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-unknown-assertion", filePath, ...p, message: "`as unknown` — redesign the types" })
|
||||
}
|
||||
}
|
||||
|
||||
// ── enum ──
|
||||
if (ts.isEnumDeclaration(node)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-enum", filePath, ...p, message: "`enum` — use `as const` object + literal union type" })
|
||||
}
|
||||
|
||||
// ── x! non-null assertion ──
|
||||
if (ts.isNonNullExpression(node)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!` — use narrowing or optional chaining" })
|
||||
}
|
||||
|
||||
// ── throw "literal" ──
|
||||
if (ts.isThrowStatement(node) && node.expression) {
|
||||
const expr = node.expression
|
||||
if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-throw-literal", filePath, ...p, message: "`throw literal` — throw an Error subclass" })
|
||||
}
|
||||
if (ts.isTemplateExpression(expr)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-throw-literal", filePath, ...p, message: "`throw template` — throw an Error subclass" })
|
||||
}
|
||||
}
|
||||
|
||||
// ── export let / export var ──
|
||||
if (ts.isVariableStatement(node)) {
|
||||
const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
||||
if (hasExport) {
|
||||
const flags = node.declarationList.flags
|
||||
if (!(flags & ts.NodeFlags.Const)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-mutable-export", filePath, ...p, message: "`export let/var` — use `export const`" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── : any in annotations ──
|
||||
if (ts.isTypeReferenceNode(node) || node.kind === ts.SyntaxKind.AnyKeyword) {
|
||||
if (node.kind === ts.SyntaxKind.AnyKeyword && !lineHasOptOut(node)) {
|
||||
const parent = node.parent
|
||||
// Skip `as any` — already caught by no-any-assertion
|
||||
if (parent && ts.isAsExpression(parent)) {
|
||||
// already handled
|
||||
} else if (parent && (
|
||||
ts.isParameter(parent) ||
|
||||
ts.isVariableDeclaration(parent) ||
|
||||
ts.isPropertyDeclaration(parent) ||
|
||||
ts.isPropertySignature(parent)
|
||||
)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-any-annotation", filePath, ...p, message: "`: any` annotation — use `unknown` and narrow" })
|
||||
} else if (parent && (
|
||||
ts.isFunctionDeclaration(parent) ||
|
||||
ts.isMethodDeclaration(parent) ||
|
||||
ts.isArrowFunction(parent) ||
|
||||
ts.isFunctionExpression(parent)
|
||||
)) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "no-explicit-any-return", filePath, ...p, message: "`(): any` return — use a specific type" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── empty catch / catch without narrowing ──
|
||||
if (ts.isCatchClause(node)) {
|
||||
const catchLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line
|
||||
const catchLineText = getLineText(sourceFile, catchLine)
|
||||
if (!CATCH_OK_RE.test(catchLineText)) {
|
||||
const body = node.block
|
||||
const stmts = body.statements
|
||||
|
||||
if (stmts.length === 0) {
|
||||
// Empty catch — swallows everything silently
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "empty-catch", filePath, ...p, message: "empty `catch` block — handle, re-throw, or remove the try/catch" })
|
||||
} else if (node.variableDeclaration) {
|
||||
// Has a bound variable — check if it's narrowed with instanceof
|
||||
const varName = node.variableDeclaration.name.getText(sourceFile)
|
||||
const blockText = body.getText(sourceFile)
|
||||
const hasInstanceof = blockText.includes(`instanceof`)
|
||||
const hasRethrow = blockText.includes(`throw ${varName}`) || blockText.includes(`throw new`)
|
||||
if (!hasInstanceof && !hasRethrow) {
|
||||
const p = pos(node)
|
||||
violations.push({ ruleId: "catch-without-narrowing", filePath, ...p, message: "`catch` without `instanceof` narrowing or re-throw — narrow the error type or re-throw" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
|
||||
// ── @ts-ignore / @ts-expect-error in comments ──
|
||||
const commentRanges = [
|
||||
...(ts.getLeadingCommentRanges(source, 0) ?? []),
|
||||
]
|
||||
// Scan all comments via regex for reliability
|
||||
const commentRegex = /\/\/\s*@ts-(ignore|expect-error)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = commentRegex.exec(source)) !== null) {
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(match.index)
|
||||
const kind = match[1]
|
||||
violations.push({
|
||||
ruleId: kind === "ignore" ? "no-ts-ignore" : "no-ts-expect-error",
|
||||
filePath,
|
||||
line: line + 1,
|
||||
column: character + 1,
|
||||
message: `\`@ts-${kind}\` — fix the underlying type`,
|
||||
})
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function formatViolation(v: Violation): string {
|
||||
return `${v.filePath}:${v.line}:${v.column}: [${v.ruleId}] ${v.message}`
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length === 0) {
|
||||
console.error("usage: check-no-excuse-rules.ts <file-or-dir>...")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const files = discoverFiles(args)
|
||||
if (files.length === 0) {
|
||||
console.error("No TypeScript files found.")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const violations = files.flatMap((f) => analyzeFile(f))
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log(`No violations in ${files.length} file(s).`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const v of violations) {
|
||||
console.error(formatViolation(v))
|
||||
}
|
||||
console.error(`\n${violations.length} violation(s) in ${files.length} file(s).`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Scaffold a new TypeScript project with ultra-strict defaults.
|
||||
*
|
||||
* ─── How to run ───
|
||||
* 1. Install Bun: curl -fsSL https://bun.sh/install | bash
|
||||
* 2. Run:
|
||||
* bun run scripts/new-project.ts my-api
|
||||
* bun run scripts/new-project.ts my-api --path ./projects
|
||||
* ──────────────────
|
||||
*
|
||||
* Creates:
|
||||
* <name>/
|
||||
* package.json (Bun + Hono + Zod + Drizzle + Biome)
|
||||
* tsconfig.json (ultra-strict from tsconfig-strict.md)
|
||||
* biome.json (strict from tsconfig-strict.md)
|
||||
* src/index.ts (minimal Hono entrypoint)
|
||||
* .gitignore
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
path: { type: "string", default: "." },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (values.help || positionals.length === 0) {
|
||||
console.log(`Usage: bun run new-project.ts <name> [--path <dir>]
|
||||
|
||||
Arguments:
|
||||
name Project directory name (kebab-case)
|
||||
|
||||
Options:
|
||||
--path Parent directory (default: current dir)
|
||||
-h, --help Show this help`);
|
||||
process.exit(positionals.length === 0 ? 2 : 0);
|
||||
}
|
||||
|
||||
const name = positionals[0]!;
|
||||
const root = resolve(values.path!, name);
|
||||
|
||||
if (existsSync(root)) {
|
||||
console.error(`Error: ${root} already exists`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Directory structure ──
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
|
||||
// ── package.json ──
|
||||
const pkg = {
|
||||
name,
|
||||
version: "0.0.1",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
dev: "bun --hot src/index.ts",
|
||||
start: "bun src/index.ts",
|
||||
check: "bunx biome check . && bunx tsc --noEmit && bun test",
|
||||
"check:fix": "bunx biome check --write .",
|
||||
test: "bun test",
|
||||
},
|
||||
dependencies: {
|
||||
hono: "^4.12.5",
|
||||
zod: "^3.24.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
"@types/bun": "latest",
|
||||
typescript: "^5.8.0",
|
||||
},
|
||||
};
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
||||
|
||||
// ── tsconfig.json (ultra-strict) ──
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
noUncheckedIndexedAccess: true,
|
||||
exactOptionalPropertyTypes: true,
|
||||
noFallthroughCasesInSwitch: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
verbatimModuleSyntax: true,
|
||||
isolatedModules: true,
|
||||
esModuleInterop: true,
|
||||
resolveJsonModule: true,
|
||||
target: "ESNext",
|
||||
lib: ["ESNext"],
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
sourceMap: true,
|
||||
outDir: "dist",
|
||||
rootDir: "src",
|
||||
module: "ESNext",
|
||||
moduleResolution: "bundler",
|
||||
types: ["bun-types"],
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["node_modules", "dist"],
|
||||
};
|
||||
writeFileSync(
|
||||
join(root, "tsconfig.json"),
|
||||
JSON.stringify(tsconfig, null, 2) + "\n",
|
||||
);
|
||||
|
||||
// ── biome.json (strict) ──
|
||||
const biome = {
|
||||
$schema: "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
organizeImports: { enabled: true },
|
||||
formatter: {
|
||||
enabled: true,
|
||||
indentStyle: "space",
|
||||
indentWidth: 2,
|
||||
lineWidth: 100,
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
complexity: {
|
||||
noBannedTypes: "error",
|
||||
noExtraBooleanCast: "error",
|
||||
noUselessConstructor: "error",
|
||||
noUselessRename: "error",
|
||||
noVoid: "error",
|
||||
},
|
||||
correctness: {
|
||||
noUnusedVariables: "error",
|
||||
noUnusedImports: "error",
|
||||
useExhaustiveDependencies: "warn",
|
||||
},
|
||||
style: {
|
||||
noNonNullAssertion: "error",
|
||||
useConst: "error",
|
||||
noParameterAssign: "error",
|
||||
},
|
||||
suspicious: {
|
||||
noExplicitAny: "error",
|
||||
noAssertion: "warn",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(join(root, "biome.json"), JSON.stringify(biome, null, 2) + "\n");
|
||||
|
||||
// ── src/index.ts ──
|
||||
const indexTs = `import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => c.json({ status: "ok" }));
|
||||
|
||||
export default app;
|
||||
`;
|
||||
writeFileSync(join(root, "src/index.ts"), indexTs);
|
||||
|
||||
// ── .gitignore ──
|
||||
const gitignore = `node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
`;
|
||||
writeFileSync(join(root, ".gitignore"), gitignore);
|
||||
|
||||
console.log(`✓ Created: ${root}`);
|
||||
console.log(` cd ${name} && bun install && bun run check`);
|
||||
Reference in New Issue
Block a user