From 4e433158cd80a34dde592be3306aeb4dcc42c191 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 5 Aug 2026 16:07:21 +0000 Subject: [PATCH] =?UTF-8?q?docs(P03):=20complete=20language-derived=20exte?= =?UTF-8?q?nsion=20=E2=80=94=20v0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: atelier phase: 3 milestone: v0.4 status: complete phase_role: execution phase_tag: v0.3.3 requirements: covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105] partial: [] ---/ci--- --- languages/go-concurrency.md | 171 ++++++++++++++++++++++++++++++++++++ languages/go-testing.md | 141 +++++++++++++++++++++++++++++ languages/go-tooling.md | 97 ++++++++++++++++++++ languages/go-types.md | 140 +++++++++++++++++++++++++++++ languages/go.md | 7 ++ languages/py-async.md | 117 ++++++++++++++++++++++++ languages/py-testing.md | 111 +++++++++++++++++++++++ languages/py-tooling.md | 99 +++++++++++++++++++++ languages/py-types.md | 111 +++++++++++++++++++++++ languages/python.md | 7 ++ languages/rs-async.md | 129 +++++++++++++++++++++++++++ languages/rs-ownership.md | 136 ++++++++++++++++++++++++++++ languages/rs-testing.md | 159 +++++++++++++++++++++++++++++++++ languages/rs-tooling.md | 95 ++++++++++++++++++++ languages/rust.md | 7 ++ languages/ts-async.md | 115 ++++++++++++++++++++++++ languages/ts-testing.md | 117 ++++++++++++++++++++++++ languages/ts-tooling.md | 114 ++++++++++++++++++++++++ languages/ts-types.md | 111 +++++++++++++++++++++++ languages/typescript.md | 7 ++ 20 files changed, 1991 insertions(+) create mode 100644 languages/go-concurrency.md create mode 100644 languages/go-testing.md create mode 100644 languages/go-tooling.md create mode 100644 languages/go-types.md create mode 100644 languages/py-async.md create mode 100644 languages/py-testing.md create mode 100644 languages/py-tooling.md create mode 100644 languages/py-types.md create mode 100644 languages/rs-async.md create mode 100644 languages/rs-ownership.md create mode 100644 languages/rs-testing.md create mode 100644 languages/rs-tooling.md create mode 100644 languages/ts-async.md create mode 100644 languages/ts-testing.md create mode 100644 languages/ts-tooling.md create mode 100644 languages/ts-types.md diff --git a/languages/go-concurrency.md b/languages/go-concurrency.md new file mode 100644 index 0000000..f58ccdb --- /dev/null +++ b/languages/go-concurrency.md @@ -0,0 +1,171 @@ +# Go Concurrency — Derived Application + +> Applies Atelier's domain principles to Go's concurrency specifically. Go's distinctive strength (goroutines, channels, context) earns a dedicated concurrency doc rather than a `go-async.md`. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/go.md` for the language first-principles stub. + +## Goroutines and Structured Concurrency (Concurrency P1 Immutability by Default, C6 Composability) + +- **`go f()` spawns a goroutine; ensure it does not outlive its parent:** an unstructured `go f()` leaks when the parent returns. Use `sync.WaitGroup`, `errgroup.Group`, or a `context`-scoped pattern to bound lifetime. +- **`errgroup.WithContext` for structured concurrency:** a `Group` cancels its context on first error; siblings see the cancellation and exit. Mirrors `TaskGroup` semantics cross-language. +- **Goroutines share only immutable inputs:** `go process(snap)` where `snap` is a copy. A goroutine sharing a mutable slice with the parent is a race (Concurrency P1 Immutability, P6 No Silent Races). +- **No `go` in a library function without a documented lifetime:** a library that spawns unbounded goroutines leaks them into the caller. Either accept a `context.Context` or return a `Stop()` method. + +```go +import "golang.org/x/sync/errgroup" + +func fetchAll(ctx context.Context, ids []string) ([]*User, error) { + g, ctx := errgroup.WithContext(ctx) + results := make([]*User, len(ids)) + for i, id := range ids { + i, id := i, id // capture loop vars + g.Go(func() error { + u, err := fetchUser(ctx, id) + if err != nil { return err } + results[i] = u + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return results, nil +} +``` + +## Channels: Bounded Queues and Backpressure (Concurrency P9 Bounded Queues, C6 Composability) + +- **Bounded channels apply backpressure:** `make(chan T, N)` blocks the sender when full (Concurrency P9 — bounded queues). Unbounded `make(chan T)` lets the producer run ahead and OOM. +- **`select` with `default` for non-blocking send/receive:** a `default` case makes the channel a queue with try semantics; without it, the operation blocks. +- **Close channel from the sender, never the receiver:** closing a channel signals "no more sends." A receiver closing it is a race; the sender may still be writing. +- **One channel, one responsibility:** do not multiplex control and data on the same channel. Use a `select` over multiple channels instead. +- **Applies `messaging/queues`:** a bounded Go channel is an in-process broker — bounded buffer, backpressure, at-most-once handoff. The same semantics apply; the broker is local. + +```go +func pipeline(ctx context.Context, in <-chan Job, out chan<- Result) { + for { + select { + case j, ok := <-in: + if !ok { return } + r := process(j) + select { + case out <- r: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } +} + +// bounded: backpressure when out is full +out := make(chan Result, 16) +``` + +## context.Context for Cancellation (Concurrency P7 Cancellation Support, Concurrency P8 Timeout Discipline) + +- **`context.Context` is the first parameter of every I/O function:** `func fetchUser(ctx context.Context, id string) (*User, error)`. A function that does I/O without a `ctx` cannot be cancelled (Concurrency P7). +- **`context.WithTimeout` for a deadline:** `ctx, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel()`. Every external call races against a deadline (Concurrency P8). +- **`cancel()` always called, even on success:** `defer cancel()` immediately after creating the context. A leaked context leaks its timer. +- **Never store a `context.Context` in a struct:** pass it as a parameter. A struct holding a `ctx` captures a request-scoped value into a long-lived object. +- **Applies `concurrency/P7`:** cancellation propagates via `ctx.Done()`. A `select` on `<-ctx.Done()` is the cancel-aware wait. + +```go +func fetchWithTimeout(ctx context.Context, url string) (*Response, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, ErrTimeout + } + return nil, err + } + return resp, nil +} +``` + +## select and Multiplexed Channels (Concurrency P7 Cancellation Support, C6 Composability) + +- **`select` multiplexes channel operations:** it picks a ready case at random (fair). A `select` with `<-ctx.Done()` plus a data case is the cancel-aware wait. +- **`default` makes `select` non-blocking:** use for "send if ready, else drop" (a bounded queue with drop-oldest policy). +- **`select {}` blocks forever:** a `select{}` with no cases is a permanent block. Use only in a goroutine that should run until the process exits. +- **Applies `concurrency/P7`:** the `select` over `ctx.Done()` and a result channel is the canonical cancel pattern. + +```go +func processUntilCancel(ctx context.Context, jobs <-chan Job) { + for { + select { + case <-ctx.Done(): + return + case j, ok := <-jobs: + if !ok { return } + // ... + } + } +} +``` + +## sync Primitives and Lock Scope (Concurrency P3 Boundaries are Locks, Concurrency P5 Lock Minimization) + +- **`sync.Mutex` scoped minimally:** not held across I/O (a `Send` on a channel, an HTTP call). Hold the lock, mutate, release — then do I/O (Concurrency P3 Lock Scope). +- **`sync.RWMutex` for read-heavy, `Mutex` for write-heavy:** RWMutex adds overhead; only prefer it when reads dominate by 10x+. +- **`sync.Map` for specific cases (append-only, disjoint keys):** not a general `map[K]V` replacement. For most maps, `Mutex` + `map` is clearer and often faster. +- **`sync.Once` for one-time init:** `var once sync.Once; once.Do(func(){ init() })`. Idempotent and race-free. +- **Applies `concurrency/P5` (lock minimization):** prefer channels over locks; when a lock is needed, hold it for the smallest possible scope. + +```go +type Cache struct { + mu sync.Mutex + items map[string]*User +} + +func (c *Cache) Get(id string) (*User, bool) { + c.mu.Lock() + defer c.mu.Unlock() + u, ok := c.items[id] + return u, ok +} + +func (c *Cache) Set(id string, u *User) { + c.mu.Lock() + c.items[id] = u + c.mu.Unlock() // explicit unlock before any I/O +} +``` + +## Race Detection (Concurrency P6 No Silent Races) + +- **`go test -race` enforces `P6`:** the race detector instruments memory accesses and fails on data races. See `go-tooling.md` for the CI gate. +- **Tests must exercise the concurrent path:** a serial test of a `Mutex`-protected map finds no race. Write tests with N goroutines hitting the map under `-race`. +- **Applies `concurrency/P6`:** a race detected at test time is a bug fixed; a race undetected is a production heisenbug. + +```go +func TestCacheConcurrent(t *testing.T) { + c := &Cache{items: map[string]*User{}} + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + c.Set(strconv.Itoa(i), &User{}) + c.Get(strconv.Itoa(i)) + }() + } + wg.Wait() +} +``` + +## Cross-References + +- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here. +- `domains/concurrency/first-principles.md` — Concurrency P1, P3, P5, P6, P7, P8, P9 traced throughout. +- `domains/messaging/queues.md` — bounded Go channels as in-process brokers; backpressure parallels (IDEATE-40). +- `domains/errors/patterns.md` — `errgroup` and error propagation in concurrent code. +- `languages/go-types.md` — typed channels carry the named types defined there. +- `languages/go-tooling.md` — the `-race` CI gate that enforces Concurrency P6. +- `languages/go-testing.md` — concurrent tests that exercise the race detector. \ No newline at end of file diff --git a/languages/go-testing.md b/languages/go-testing.md new file mode 100644 index 0000000..354a98c --- /dev/null +++ b/languages/go-testing.md @@ -0,0 +1,141 @@ +# Go Testing — Derived Application + +> Applies Atelier's domain principles to Go testing specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/go.md` for the language first-principles stub. + +## Table-Driven Tests (Testing P1 Tests as Specification, C2 Clarity) + +- **Table-driven is the Go idiom:** `cases := []struct{ name string; in X; want Y }{...}`; loop with `t.Run(c.name, ...)`. Each case is a subtest with its own name and failure output. +- **Test names read as a spec:** `{"rejects empty email", ...}`, `{"returns persisted id", ...}`. A reader understands the unit from the subtest names (Testing P1). +- **No `if got != want { t.Fatal() }` shared across cases:** each case asserts independently; a failure in case 3 does not skip cases 4 and 5. +- **`t.Run` enables `-run` filtering:** `go test -run TestCreateUser/rejects_empty_email` runs one case. Essential for debugging a single failure. + +```go +func TestCreateUser(t *testing.T) { + cases := []struct { + name string + email string + wantErr bool + }{ + {"rejects empty email", "", true}, + {"rejects missing @", "no-at-sign", true}, + {"accepts valid email", "a@b.co", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := CreateUser(c.email) + if (err != nil) != c.wantErr { + t.Fatalf("err=%v, wantErr=%v", err, c.wantErr) + } + }) + } +} +``` + +## t.Parallel for Independence (Testing P2 Independence, Concurrency P10 Test for Race Conditions) + +- **`t.Parallel()` for independent subtests:** each subtest opts in; the runner executes them concurrently. A test that fails under `Parallel` has hidden state (Testing P2 Independence). +- **Capture loop variables:** `c := c` inside the loop, or rely on Go 1.22+ per-iteration scoping. A parallel subtest sharing `c` races on the last value. +- **Applies `concurrency/P10` (test for races):** parallel tests are the first line of race detection; combine with `-race` for the full safety net. + +```go +for _, c := range cases { + c := c // capture for parallel + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, err := CreateUser(c.email) + if (err != nil) != c.wantErr { + t.Fatalf("err=%v, wantErr=%v", err, c.wantErr) + } + }) +} +``` + +## t.Cleanup for Teardown (Testing P3 Determinism, Testing P2 Independence) + +- **`t.Cleanup(func() { ... })` for teardown:** runs in LIFO order after the test (and its subtests) complete. Replaces `defer` in a helper that does not know when the test ends. +- **Per-test state, not shared:** a `setup(t)` helper creates resources and registers cleanup; each test gets its own. A package-level `var` shared across tests is order coupling. +- **`t.TempDir()` for filesystem tests:** creates a unique temp dir and cleans up automatically. No manual `os.RemoveAll` and no cross-test contamination. +- **Applies `Testing P3` (determinism):** cleanup is tied to the test lifecycle, not a global teardown that may run before or after depending on order. + +```go +func setupStore(t *testing.T) *Store { + t.Parallel() + dir := t.TempDir() // auto-cleaned + s, err := OpenStore(filepath.Join(dir, "db")) + if err != nil { t.Fatal(err) } + t.Cleanup(func() { s.Close() }) + return s +} +``` + +## Race Detector (Testing P9 Edge Case Coverage, Concurrency P6 No Silent Races) + +- **`go test -race` in CI, always:** see `go-tooling.md`. The detector is the enforcement of `concurrency/P6`. +- **Tests must exercise the concurrent path:** a serial test of a `Mutex`-protected map finds no race. Write tests with N goroutines. +- **`-count=1` to disable result caching:** by default, Go caches passing tests. `-count=1` forces re-run; combine with `-race` and parallelism to surface heisenbugs. +- **Applies `Testing P9` (edge case coverage):** the race detector is the edge-case tool for concurrency — it finds the inputs the test author forgot to write. + +```bash +# CI gate +go test -race -count=1 ./... +``` + +## Time and Determinism (Testing P3 Determinism, Testing P9 Edge Case Coverage) + +- **No `time.Now()` in code under test:** inject a `Clock` interface. In tests, a fake clock advances deterministically. +- **`time.Sleep` in tests is a smell:** a sleep waits for a real timer, flaky under load. Use a channel or `Eventually`-style polling with a timeout. +- **`t.Deadline()` aware helpers:** a helper that may take long checks `t.Deadline()` and bails early. Prevents a slow test from timing out the suite. + +```go +type Clock interface { Now() time.Time } + +type fakeClock struct{ t time.Time } +func (f *fakeClock) Now() time.Time { return f.t } + +func TestUserHasCreatedAt(t *testing.T) { + clk := &fakeClock{time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)} + u, _ := CreateUserWithClock("a@b.co", clk) + if u.CreatedAt.Year() != 2024 { + t.Fatalf("year=%d, want 2024", u.CreatedAt.Year()) + } +} +``` + +## Mocks and Interfaces (Testing P7 Realism, API P1 Contract Fidelity) + +- **Mock at the interface, not the struct:** `type Store interface { Get(id string) (*User, error) }` in production; `type mockStore struct{ ... }` in test. The interface is the contract (applies `api/P1`). +- **`httptest` for HTTP servers:** `httptest.NewServer` gives a real server on a loopback port; no manual socket plumbing. +- **`testify/mock` or hand-written mocks:** hand-written for one-off, `testify` for complex sequencing. Avoid mocking frameworks that generate code at runtime (reflection-heavy) — they hide failures behind stack traces. +- **Applies `Testing P7` (realism):** mock the boundary (HTTP, DB), not the unit. Mocking the unit under test tests the mock. + +```go +type mockStore struct { + users map[string]*User + got []string +} +func (m *mockStore) Get(id string) (*User, error) { + m.got = append(m.got, id) + return m.users[id], nil +} + +func TestGetUserLogs(t *testing.T) { + s := &mockStore{users: map[string]*User{"abc": {}}} + svc := NewService(s) + svc.GetUser("abc") + if len(s.got) != 1 || s.got[0] != "abc" { + t.Fatalf("got=%v", s.got) + } +} +``` + +## Cross-References + +- `domains/testing/pyramid.md` — where unit/integration/race tests sit; the race job is its own layer. +- `domains/testing/fixtures.md` — `t.TempDir` and `t.Cleanup` as the fixture discipline. +- `domains/testing/first-principles.md` — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage. +- `domains/concurrency/first-principles.md` — Concurrency P6 (race detector), P10 (test for races). +- `languages/go-types.md` — the named types tests assert. +- `languages/go-concurrency.md` — concurrent tests exercise the patterns from that doc. +- `languages/go-tooling.md` — the `go test` flags (`-race`, `-count`, `-run`) detailed here. \ No newline at end of file diff --git a/languages/go-tooling.md b/languages/go-tooling.md new file mode 100644 index 0000000..11ada77 --- /dev/null +++ b/languages/go-tooling.md @@ -0,0 +1,97 @@ +# Go Tooling — Derived Application + +> Applies Atelier's domain principles to Go tooling specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/go.md` for the language first-principles stub. + +## go vet and golangci-lint (DevOps P2 Automation, C2 Clarity) + +- **`go vet` is the stdlib baseline:** it catches `printf` format mismatches, lock-copy-by-value, and unreachable code. Run on every build. +- **`golangci-lint` aggregates vet + dozens of linters:** enable `errcheck` (no `_ = err`), `govet`, `staticcheck`, `ineffassign`, `unused`, `gofmt`, `goimports`. Each enabled linter has a one-line `# reason:` in `.golangci.yml`. +- **`errcheck` enforces `errors/P2` (fail loudly):** a discarded error is a silent failure. `errcheck` fails the build on `_ = doX()`. +- **`goimports` over `gofmt`:** `goimports` adds missing imports and removes unused ones, in addition to formatting. The format is not debated in review (Clarity C2). + +```yaml +# .golangci.yml +linters: + enable: + - errcheck # reason: Errors P2 — no swallowed errors + - govet + - staticcheck + - ineffassign + - unused + - gofmt + - goimports +linters-settings: + errcheck: + check-blank: true # fail on _ = fn() +``` + +## go test -race (Concurrency P6 No Silent Races) + +- **`go test -race` in CI, always:** the race detector instruments memory accesses and fails on data races. It is the primary enforcement of `concurrency/P6` (no silent races). +- **`-race` adds overhead; run it in a separate CI job:** the race build is ~2x slower; keep the fast unit-test job and add a race job. +- **`-race` requires tests that actually exercise the concurrent path:** a test that calls `Get`/`Set` serially finds no race. Write tests that spawn goroutines hitting the same map. +- **Applies `concurrency/P6`:** a race detected is a bug fixed; a race undetected is a heisenbug in production. The detector is the safety net. + +```bash +# CI race job +go test -race -count=1 ./... +``` + +## Module Discipline (DevOps P1 Reproducibility) + +- **`go mod tidy` on every change that touches imports:** removes unused deps and adds missing ones. A `go.mod` with stale entries breaks reproducibility. +- **`go.sum` committed and verified:** `go mod verify` checks the checksums of the module cache against `go.sum`. A drifted `go.sum` is a supply-chain signal. +- **Pinned major versions in `go.mod`:** `require github.com/x/y v1.2.3` pins the minor; a `v1.2.4` patch may auto-update. For applications, consider a `go.mod` proxy that pins to exact commits. +- **`go mod vendor` for hermetic CI:** vendoring `vendor/` into the repo means CI builds without network. The trade-off is repo size; the win is reproducibility (DevOps P1). + +```bash +# CI build gate +go mod tidy +go mod verify +go build ./... +go test -race ./... +``` + +## Reproducible Builds (DevOps P1 Reproducibility, C3 Simplicity) + +- **One Go toolchain version, pinned:** `goenv` or `asdf` pins the Go version per repo; a `.go-version` file declares it. A CI job that uses "latest" Go drifts. +- **`CGO_ENABLED=0` for static binaries:** a static binary runs in a scratch container with no libc dependency. Set in CI for all release builds. +- **`-trimpath` and `-ldflags='-s -w'` for reproducible output:** strips the build path from the binary and removes debug info. Two builds of the same commit produce byte-identical binaries. + +```bash +# Reproducible release build +CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o app ./cmd/app +``` + +## Documentation in the Pipeline (Documentation P1 Documentation is Code, DevOps P9 Documentation in the Pipeline) + +- **`go doc` from comments:** package comments and exported-symbol comments are the API docs; `go doc` and `pkg.go.dev` render them. Missing comments on exported symbols fail `revive`/`golint` (Documentation P1). +- **`// Example` functions are run by `go test`:** an `ExampleUser` function with `// Output:` is a tested artifact; a stale output fails the build. +- **`README.md` and `docs/` are built by `mkdocs` or similar:** the pipeline validates links and renders; a broken link fails CI (Documentation P1). + +```go +// GetUser fetches a user by id. +// +// Example: +// +// u, err := GetUser(id) +// if err != nil { ... } +func GetUser(id UserId) (*User, error) { /* ... */ } + +func ExampleGetUser() { + u, err := GetUser("abc") + fmt.Println(u, err) + // Output: not found +} +``` + +## Cross-References + +- `domains/devops/ci-cd.md` — the pipeline gates that host vet/lint/test. +- `domains/devops/first-principles.md` — DevOps P1 Reproducibility, P2 Automation. +- `domains/concurrency/first-principles.md` — Concurrency P6 No Silent Races (`-race`). +- `domains/documentation/first-principles.md` — Documentation P1 Documentation is Code. +- `languages/go-types.md` — the type rules staticcheck enforces reference this doc. +- `languages/go-testing.md` — the `go test` flags (`-race`, `-count`) detailed here. \ No newline at end of file diff --git a/languages/go-types.md b/languages/go-types.md new file mode 100644 index 0000000..8561b11 --- /dev/null +++ b/languages/go-types.md @@ -0,0 +1,140 @@ +# Go Type System — Derived Application + +> Applies Atelier's domain principles to Go's type system specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/go.md` for the language first-principles stub. + +## Named Types for Domain Concepts (C1 Correctness, Data P7 Type Fidelity) + +- **Named types for domain IDs and values:** `type UserId string`, `type OrderId string`. Two named types are distinct even with identical underlying types; the compiler rejects the swap. +- **Constructors validate at the boundary:** `func NewUserId(s string) (UserId, error)` returns an error on bad input. A bare `UserId(s)` cast bypasses validation — only the constructor is exported. +- **Applies `data/P7` (type fidelity):** a named type carries the domain meaning through the call graph; a `string` parameter does not. +- **`any` is the wide type; narrow before use:** Go 1.18+ `any` is an alias for `interface{}`. Use it only at true boundaries (e.g., `json.Unmarshal`); narrow with a type assertion immediately. + +```go +type UserId string +type OrderId string + +func NewUserId(s string) (UserId, error) { + if !regexp.MustCompile(`^[a-z0-9]+$`).MatchString(s) { + return "", fmt.Errorf("invalid user id: %q", s) + } + return UserId(s), nil +} + +func GetUser(id UserId) (*User, error) { /* ... */ } + +// GetUser("abc") // compile error: string is not UserId +// GetUser(OrderId("abc")) // compile error: distinct named types +``` + +## Generics (C6 Composability, Data P7 Type Fidelity) + +- **Generics (1.18+) preserve element types across containers:** `type Repository[T any] struct { ... }` keeps `T` through `Get`/`Save`, rather than widening to `any`. +- **Constrain with `comparable` for map keys, custom interfaces for behavior:** `func dedupe[T comparable](s []T) []T` uses `comparable`; a `Sortable[T]` constraint expresses the `Less` requirement. +- **Avoid generics where an interface suffices:** `io.Reader` is not improved by generics. Generics are for type-preserving containers; interfaces are for behavior. +- **No generic methods on generic types (not supported):** `func (r Repository[T]) Map[U any](f func(T) U) Repository[U]` is a compile error. Use a free function. + +```go +type Entity interface { ID() string } + +type Repository[T Entity] struct { + db map[string]T +} + +func (r *Repository[T]) Get(id string) (T, bool) { + var zero T + t, ok := r.db[id] + if !ok { return zero, false } + return t, true +} + +func (r *Repository[T]) Save(t T) { r.db[t.ID()] = t } +``` + +## Interfaces (C6 Composability, API P1 Contract Fidelity) + +- **Interfaces defined by the consumer, not the producer:** a package defines its dependencies as interfaces (`type Store interface { Get(id string) (*User, error) }`), and accepts implementations. The producer does not pre-declare "the interface I implement." +- **Small interfaces (Go proverb):** `io.Reader` is one method. An interface with 5+ methods is a god-object; split it. +- **Accept interfaces, return concrete types:** return a `*UserRepo`, accept a `Store`. The caller gets the implementation; the callee depends on the abstraction. +- **Applies `api/P1` (contract fidelity):** the interface is the contract; the concrete type is the implementation. Tests mock the interface, not the struct. + +```go +// consumer defines the interface +type UserStore interface { + Get(id string) (*User, error) +} + +type Service struct { store UserStore } + +func NewService(s UserStore) *Service { return &Service{store: s} } + +// producer returns concrete; satisfies UserStore implicitly +type UserRepo struct { db map[string]*User } +func (r *UserRepo) Get(id string) (*User, error) { return r.db[id], nil } +``` + +## Type Assertion Discipline (C1 Correctness, Errors P1 Errors are Data) + +- **Type assertions return `(T, bool)` — use the bool:** `v, ok := x.(UserId)` distinguishes "wrong type" from "zero value." A bare `x.(UserId)` panics on mismatch. +- **`switch x := x.(type)` for multi-variant narrowing:** each case narrows `x` to the case type. The default case is exhaustive (no `never`-style check; Go relies on review). +- **Applies `errors/P1` (errors are data):** a failed type assertion is a value (`ok == false`), not an exception. Handle it as a branch, not a panic. +- **Never assert across module boundaries silently:** an assertion on a type from another package couples to its internals. Prefer an interface method. + +```go +func describe(x any) string { + switch v := x.(type) { + case UserId: + return "user " + string(v) + case OrderId: + return "order " + string(v) + default: + return fmt.Sprintf("unknown: %T", v) + } +} + +// safe form, never panic +id, ok := raw.(UserId) +if !ok { + return fmt.Errorf("expected UserId, got %T", raw) +} +``` + +## Error Types and errors.Is/As (Errors P1 Errors are Data, Errors P3 Fail Specifically) + +- **Sentinel errors for known cases:** `var ErrNotFound = errors.New("not found")`; check with `errors.Is(err, ErrNotFound)`. The sentinel is a value, not an exception class. +- **Custom error types for context:** `type ValidationError struct { Field, Msg string }`; check with `var ve *ValidationError; errors.As(err, &ve)`. The type carries structured data (Errors P4 Preserve Context). +- **Wrap with `%w`:** `fmt.Errorf("get user %s: %w", id, err)` preserves the chain. `errors.Is`/`As` unwrap it. Bare `%v` breaks the chain. +- **Applies `errors/P3` (fail specifically):** `ErrNotFound` is specific; `ErrFailed` is not. The error type names the failure mode. + +```go +var ErrNotFound = errors.New("not found") + +type ValidationError struct { + Field string + Msg string +} +func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg } + +func GetUser(id UserId) (*User, error) { + u, ok := db[string(id)] + if !ok { + return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) + } + return u, nil +} + +// caller +if errors.Is(err, ErrNotFound) { /* 404 */ } +var ve *ValidationError +if errors.As(err, &ve) { /* 422 with ve.Field */ } +``` + +## Cross-References + +- `domains/data/schema-design.md` — named types parallel schema design at the Go boundary. +- `domains/data/first-principles.md` — Data P7 Type Fidelity is the primary trace. +- `domains/api/rest.md` — contract fidelity for HTTP handlers using interfaces. +- `domains/errors/patterns.md` — `errors.Is`/`As` and the wrap-with-`%w` pattern. +- `languages/go-concurrency.md` — typed channels carry these named types. +- `languages/go-testing.md` — table-driven tests assert type-swap safety. \ No newline at end of file diff --git a/languages/go.md b/languages/go.md index 51bb07f..690e879 100644 --- a/languages/go.md +++ b/languages/go.md @@ -2,6 +2,13 @@ > How Atelier's domain principles apply in Go specifically. Derives from `domains/` docs. +## Derived Docs + +- [go-types.md](go-types.md) — named types, generics, interfaces, type assertion discipline. +- [go-tooling.md](go-tooling.md) — go vet, golangci-lint, go test -race, module discipline. +- [go-concurrency.md](go-concurrency.md) — goroutines, channels, context, select, sync primitives. +- [go-testing.md](go-testing.md) — table-driven tests, t.Parallel, t.Cleanup, race detector. + ## Type System (C1 Correctness, Data P7 Type Fidelity) - **Named types for domain concepts:** `type UserId string`, not bare `string`. diff --git a/languages/py-async.md b/languages/py-async.md new file mode 100644 index 0000000..c1fa5ac --- /dev/null +++ b/languages/py-async.md @@ -0,0 +1,117 @@ +# Python Async — Derived Application + +> Applies Atelier's domain principles to Python async specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/python.md` for the language first-principles stub. + +## asyncio and anyio (Concurrency P7 Cancellation Support, C2 Clarity) + +- **`asyncio` for I/O-bound work; threads only for blocking libraries:** `async def` + `await` for network/disk; `run_in_executor` to wrap a blocking call. Mixing threads for I/O is the wrong default. +- **`anyio` for runtime portability:** `anyio` abstracts asyncio/trio; a library written against `anyio` runs on either. Use it for libraries; for applications, asyncio directly is fine. +- **One event loop, one thread:** `asyncio.run(main())` creates and runs the loop. Do not call `asyncio.run` inside an existing loop (raises `RuntimeError`); do not share a loop across threads. +- **Applies `concurrency/P7`:** every `async def` accepts cancellation as a first-class signal; `CancelledError` propagates unless explicitly suppressed (and suppressing it is almost always a bug). + +```python +import asyncio +import anyio + +async def fetch_user(id: str) -> User: + return await api.get(f'/users/{id}') + +# asyncio application +async def main(): + user = await fetch_user('abc') + +asyncio.run(main()) + +# anyio library — portable across asyncio/trio +async def fetch_all(ids: list[str]) -> list[User]: + return await anyio.gather(*(fetch_user(i) for i in ids)) +``` + +## Structured Concurrency (Concurrency P1 Immutability by Default, C6 Composability) + +- **`asyncio.TaskGroup` (3.11+) for structured concurrency:** tasks created in a `TaskGroup` are awaited or cancelled together on exit. No orphan tasks outlive the block. +- **No `asyncio.gather(..., return_exceptions=False)` for fallible tasks:** `gather` returns partial results on first exception; `TaskGroup` cancels siblings and propagates the error atomically. Use `TaskGroup` for new code. +- **Applies `concurrency/P1` (immutability):** tasks share only immutable inputs; results are collected, not mutated in place. A task that writes to a shared list is a race waiting to happen. +- **`anyio.create_task_group()` mirrors `TaskGroup` cross-runtime:** same structured-concurrency guarantee, portable. + +```python +import asyncio + +async def fetch_all(ids: list[str]) -> list[User]: + results: list[User] = [] + async with asyncio.TaskGroup() as tg: + tasks = [tg.create_task(fetch_user(i)) for i in ids] + # all tasks done (or cancelled) by here + return [t.result() for t in tasks] +``` + +## Cancellation and Timeout (Concurrency P7 Cancellation Support, Concurrency P8 Timeout Discipline) + +- **`asyncio.wait_for(coro, timeout)` for a deadline:** every external `await` races against a timeout. A bare `await` is an unbounded wait (Concurrency P8). +- **`asyncio.timeout()` (3.11+) as a context manager:** `async with asyncio.timeout(5): await op` — cleaner than `wait_for` for multi-await blocks. +- **`CancelledError` propagates; do not catch broadly:** `except Exception` swallows `CancelledError` in 3.7 (it was `BaseException`); in 3.8+ it's `BaseException` and `except Exception` skips it. Catch specifically, never bare `except:`. +- **Applies `concurrency/P7`:** cancellation is cooperative — a long synchronous block inside `async def` ignores cancellation. Yield with `await asyncio.sleep(0)` periodically in CPU-bound loops. + +```python +import asyncio + +async def fetch_with_timeout(id: str, timeout: float = 5.0) -> User: + async with asyncio.timeout(timeout): + return await fetch_user(id) + +async def shutdown(token: asyncio.Event) -> None: + # cooperative cancel — long-running loop checks the token + while not token.is_set(): + await do_chunk() + await asyncio.sleep(0) # yield so cancel can land +``` + +## Bounded Concurrency and Queues (Concurrency P9 Bounded Queues) + +- **`asyncio.Semaphore(N)` to bound in-flight tasks:** a `Semaphore(8)` wrapping `gather` caps concurrency. Unbounded `gather` on a 10k-item list exhausts file descriptors (Concurrency P9 — bounded queues). +- **`asyncio.Queue(maxsize=N)` for producer/consumer:** a bounded queue applies backpressure to the producer. An unbounded queue lets the producer run ahead and OOM. +- **Applies `messaging/queues`:** an `asyncio.Queue` is an in-process broker — the same bounded-queue / backpressure semantics apply; the broker is just local. + +```python +import asyncio + +async def map_bounded(items: list[str], limit: int = 8) -> list[User]: + sem = asyncio.Semaphore(limit) + async def guarded(i: str) -> User: + async with sem: + return await fetch_user(i) + return await asyncio.gather(*(guarded(i) for i in items)) +``` + +## Error Handling in Async (Errors P5 Recoverable When Possible, Errors P1 Errors are Data) + +- **Retry with backoff for transient failures:** network blips are recoverable (Errors P5). Exponential backoff with jitter, capped retries, and an `anyio`-cancellation-aware `sleep`. +- **No retry for non-idempotent operations:** a `POST` creating a resource is not safely retryable without an idempotency key (applies `api/P6` Idempotency). +- **`except asyncio.CancelledError: raise`** is the only valid handling — re-raise so the cancellation propagates. Catching and continuing breaks structured concurrency. +- **Applies `messaging/delivery-semantics`:** a cancelable async operation is at-most-once; retry-on-cancel is at-least-once. The caller must declare which. + +```python +import anyio +import random + +async def fetch_retry(id: str, attempts: int = 3) -> User: + for i in range(attempts): + try: + return await fetch_user(id) + except (TimeoutError, ConnectionError): + if i == attempts - 1: + raise + await anyio.sleep((2 ** i) * 0.1 + random.random() * 0.1) + raise RuntimeError('unreachable') +``` + +## Cross-References + +- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here. +- `domains/concurrency/first-principles.md` — Concurrency P1 Immutability, P7 Cancellation Support, P8 Timeout Discipline, P9 Bounded Queues. +- `domains/messaging/queues.md` — `asyncio.Queue` as an in-process broker; backpressure parallels (IDEATE-40). +- `domains/errors/patterns.md` — typed async errors and retry-with-backoff. +- `languages/py-types.md` — `Result` and exception hierarchy used in async error handling. +- `languages/py-tooling.md` — `pytest-asyncio` config that runs these tests. \ No newline at end of file diff --git a/languages/py-testing.md b/languages/py-testing.md new file mode 100644 index 0000000..62e6ae3 --- /dev/null +++ b/languages/py-testing.md @@ -0,0 +1,111 @@ +# Python Testing — Derived Application + +> Applies Atelier's domain principles to Python testing specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/python.md` for the language first-principles stub. + +## pytest and Spec-Driven Tests (Testing P1 Tests as Specification, C2 Clarity) + +- **`pytest` is the default; `unittest` only for stdlib-only libraries:** `pytest` fixtures, parametrize, and assertion rewriting beat `unittest`'s boilerplate (Clarity C2). +- **Tests co-located with source:** `user.py` → `test_user.py`. A test far from its subject rots (Documentation P5 Discoverability). +- **Test names read as a spec:** `def test_create_user_rejects_invalid_email():` — a reader understands the unit from the name. Avoid `def test_user1():`. +- **`assert` over `self.assertEqual`:** pytest rewrites `assert` to show the failing values; `assertEqual` is unittest's escape hatch and loses readability. +- **Applies `Testing P1`:** the test is a specification; the failure message is the spec violation. + +```python +# test_user.py +import pytest +from user import create_user, ValidationError + +def test_create_user_rejects_invalid_email(): + with pytest.raises(ValidationError): + create_user(email='not-an-email') + +def test_create_user_returns_persisted_id(): + u = create_user(email='a@b.co') + assert u.id # truthy persisted id +``` + +## Factories and Fixture Discipline (Testing P2 Independence, Testing P7 Realism) + +- **`factory_boy` or `pytest-factoryboy` over shared fixtures for mutable state:** `UserFactory.build()` returns a fresh object per call; a session-scoped fixture mutated across tests couples them (Testing P2 Independence). +- **Fixtures for setup/teardown, factories for data:** a `db` fixture sets up the DB once per test; a `make_user` factory produces fresh data per assertion. Conflating them produces order-dependent tests. +- **`scope='function'` is the default and the safe default:** `scope='session'` for read-only resources (a schema migration), never for mutable state. +- **Mock at the boundary, not the unit:** `mocker.patch('requests.get')` for HTTP; do not patch `user.User.save` (that mocks the unit under test — Testing P7 realism). + +```python +import factory +from user import User + +class UserFactory(factory.Factory): + class Meta: + model = User + email = factory.Sequence(lambda n: f'u{n}@b.co') + name = 'Test User' + +def test_user_factory_is_fresh(): + u1 = UserFactory.build() + u2 = UserFactory.build() + assert u1.email != u2.email # independent +``` + +## Parametrize and Edge Cases (Testing P9 Edge Case Coverage, Testing P3 Determinism) + +- **`@pytest.mark.parametrize` for input tables:** one parametrized test runs N cases; each is an independent test with its own name and failure output (Testing P9). +- **Edge cases as rows, not special tests:** empty list, `None`, max int, unicode — each a row. An ad-hoc `test_handles_edge` with multiple asserts hides which case failed (Testing P6 Failure Specificity). +- **`pytest --randomly` catches order coupling:** a test passing alone but failing in a suite has hidden shared state. The random plugin makes it visible (Testing P2 Independence). +- **Property tests via `hypothesis`:** for invariants (e.g., "parse(serialize(x)) == x"), `hypothesis` generates hundreds of inputs and shrinks failures to a minimal counterexample. + +```python +import pytest + +@pytest.mark.parametrize('email, reason', [ + ('', 'empty'), + ('a' * 1000 + '@b.co', 'too long'), + ('no-at-sign', 'missing @'), + ('a@b', 'missing TLD'), +]) +def test_create_user_rejects(email, reason): + with pytest.raises(ValidationError): + create_user(email=email) +``` + +## Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage) + +- **No `datetime.now()`, `time.time()`, `uuid.uuid4()`, `random.random()` in code under test:** inject a `Clock`, `UUIDGen`, `Random` port. In tests, provide deterministic fakes. +- **`freezegun` for time:** `@freeze_time('2024-01-01')` makes `datetime.now()` deterministic. Do not call `datetime.now()` directly in code — wrap it in a `Clock` port so production and tests both inject. +- **`pytest --randomly-seed=last` to reproduce a failing order:** when `--randomly` finds an order bug, the seed is logged; re-run with it to debug deterministically. + +```python +from freezegun import freeze_time + +@freeze_time('2024-01-01') +def test_user_has_created_at(): + u = create_user(email='a@b.co') + assert u.created_at.year == 2024 +``` + +## Async Tests (Concurrency P10 Test for Race Conditions, Testing P1 Tests as Specification) + +- **`pytest-asyncio` (or `anyio`'s pytest plugin) for `async def` tests:** `@pytest.mark.asyncio` runs the coroutine on a loop. Without it, an `async def` test is silently skipped (returns a coroutine, never awaited). +- **`anyio`'s plugin runs the same test on asyncio and trio:** one parametrized run across both runtimes catches runtime-specific bugs. +- **Race-sensitive tests use `--randomly` and bounded concurrency:** a `Semaphore(1)` test under random order surfaces hidden state. +- **Applies `concurrency/P10`:** async tests are the race detector's first line — if a test passes alone but fails under `gather` of N, there's a race. + +```python +import pytest + +@pytest.mark.asyncio +async def test_async_fetch_returns_user(): + u = await fetch_user('abc') + assert u.email +``` + +## Cross-References + +- `domains/testing/pyramid.md` — where unit/integration/property tests sit; hypothesis is the property layer. +- `domains/testing/fixtures.md` — factory-vs-fixture discipline applied via `factory_boy`. +- `domains/testing/first-principles.md` — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage. +- `languages/py-types.md` — the `Result` and Pydantic models that tests assert. +- `languages/py-async.md` — async tests use the cancellation/timeout patterns from that doc. +- `languages/py-tooling.md` — the `pyproject.toml [tool.pytest]` config that runs these tests. \ No newline at end of file diff --git a/languages/py-tooling.md b/languages/py-tooling.md new file mode 100644 index 0000000..95e9a33 --- /dev/null +++ b/languages/py-tooling.md @@ -0,0 +1,99 @@ +# Python Tooling — Derived Application + +> Applies Atelier's domain principles to Python tooling specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/python.md` for the language first-principles stub. + +## ruff for Lint and Format (DevOps P2 Automation, C2 Clarity) + +- **`ruff` replaces flake8 + black + isort + pyupgrade:** one tool, one config, one order of magnitude faster. Format is not debated in review (Clarity C2). +- **Rule selection is principled, not "everything":** `select = ["E", "F", "I", "UP", "B", "SIM"]` — each rule group has a one-line `# reason:` in `pyproject.toml`. Rules without a rationale are noise (Documentation P1 — docs are code). +- **`ruff format` is the formatter, `ruff check` is the linter:** run both in CI; the formatter is deterministic, the linter surfaces smells. +- **Applies `devops/P2`:** the format/lint gate runs on every push; a developer never waits for a reviewer to comment on style. + +```toml +# pyproject.toml +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] +# reason: E/F = pyflakes+pycodestyle; I = isort; UP = pyupgrade; B = bugbear; SIM = simplification + +[tool.ruff.format] +quote-style = "double" +``` + +## mypy and Type-Check Gate (DevOps P2 Automation, Data P7 Type Fidelity) + +- **`mypy --strict` in CI, not in the editor:** strict flags (`disallow_untyped_defs`, `no_implicit_optional`, `warn_return_any`) are the floor. The editor runs a relaxed mypy for speed; CI runs strict as the gate. +- **`pyright` for stricter/async-aware checking:** pyright understands `async` better and reports faster; mypy is the standard. Pick one as the gate, run the other as informational. +- **Per-module overrides only with a tracked reason:** `[[tool.mypy.overrides]] module = "legacy.*" ignore_errors = true` — each override block links to a ticket. Untracked overrides accumulate into a permanently untyped core. +- **`py.typed` marker for libraries:** ships the type info to consumers. Without it, downstream mypy treats the library as `Any`. + +```bash +# CI gate +mypy --strict src/ +pyright src/ || true # informational +``` + +## Dependency Management: poetry and uv (DevOps P1 Reproducibility) + +- **`poetry` or `uv` for lockfile discipline:** both produce a deterministic lock (`poetry.lock` / `uv.lock`). `pip install` alone does not — it resolves at install time, producing different trees across machines. +- **`uv` for speed (Rust-based, 10–100x faster):** newer tool, same lockfile semantics. Either is acceptable; do not mix within a repo. +- **Lockfile committed for applications:** for libraries, commit the lock for CI reproducibility even though consumers resolve their own tree. +- **`--frozen` install in CI:** `poetry install --no-dev --frozen` fails if the lock is out of sync. Prevents a "works on my machine" drift. + +```bash +# CI install — deterministic +uv sync --frozen --no-dev +# or +poetry install --no-dev --frozen +``` + +## Virtualenv Discipline (DevOps P1 Reproducibility, C3 Simplicity) + +- **One virtualenv per project, never the system Python:** `uv venv` or `python -m venv .venv`. System Python drift breaks reproducibility. +- **`uv` creates and pins the Python version:** `uv venv --python 3.12` ensures the same interpreter across machines. A pinned Python is part of the reproducibility contract, not just the lockfile. +- **No `pip install` into the system Python in CI:** use `uv`/`poetry`'s venv. A CI step that mutates system Python makes the next job non-hermetic. + +```bash +uv venv --python 3.12 +source .venv/bin/activate +uv pip install -r requirements.txt +``` + +## Documentation in the Pipeline (Documentation P1 Documentation is Code, DevOps P9 Documentation in the Pipeline) + +- **`mkdocs` + `mkdocstrings` from docstrings:** API docs are generated from `google`- or `numpy`-style docstrings; the build fails on missing docstrings for public symbols (Documentation P1). +- **`doctest` blocks in docstrings are run by pytest:** a `>>>` example is a tested artifact; a stale example fails the build (Documentation P1, Testing P1). +- **`pyproject.toml` is the single source of tool config:** ruff, mypy, pytest, poetry all read from it. Do not scatter `.flake8`, `setup.cfg`, `mypy.ini`. One config file is one place to look (Clarity C2). + +```python +def get_user(id: UUID) -> User: + """Fetch a user by id. + + Args: + id: the user's UUID. + + Returns: + The User. + + Raises: + NotFoundError: if the user does not exist. + + Example: + >>> get_user(UUID('intentional-example-uuid')) + User(...) + """ + ... +``` + +## Cross-References + +- `domains/devops/ci-cd.md` — the pipeline gates that host ruff/mypy/poetry. +- `domains/devops/first-principles.md` — DevOps P1 Reproducibility, P2 Automation. +- `domains/documentation/first-principles.md` — Documentation P1 Documentation is Code. +- `languages/py-types.md` — the type rules mypy enforces reference this doc. +- `languages/py-testing.md` — the pytest config (`pyproject.toml [tool.pytest]`) detailed here. \ No newline at end of file diff --git a/languages/py-types.md b/languages/py-types.md new file mode 100644 index 0000000..875982d --- /dev/null +++ b/languages/py-types.md @@ -0,0 +1,111 @@ +# Python Type System — Derived Application + +> Applies Atelier's domain principles to Python's type system specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/python.md` for the language first-principles stub. + +## Type Hints and Gradual Typing (C1 Correctness, Data P7 Type Fidelity) + +- **Type hints on every function signature:** `def get_user(id: UUID) -> User | None:`. Hints are annotations, not enforcement, but `mypy`/`pyright` make them a build gate. +- **Gradual typing is opt-in, not opt-out:** start with `--strict` on a package, fix the errors, then expand. A repo-wide `# type: ignore` is a gradual-typing failure. +- **`from __future__ import annotations` for forward refs:** all annotations are strings until resolved, so `class User: ...` referencing `User` works without quotes in 3.10+. +- **`Any` disables the checker; `object` is the wide type:** `Any` allows any operation; `object` requires narrowing. Use `object` for opaque inputs (e.g., `json.loads` return). +- **Applies `data/P7` (type fidelity):** a hint is the contract; the checker verifies it. A missing hint is a missing contract. + +- **Pydantic for runtime validation at the boundary:** hints on `BaseModel` fields are validated at construction, catching bad input from network/config before it deepens into the system. +- **`extra='forbid'` by default:** Pydantic allows extra fields silently; forbid them to surface schema drift (e.g., a client sending a typo'd field name). +- **Custom types via `Annotated` with validators:** `Email = Annotated[str, validate_email]` keeps the type readable and the validator attached to the type, not the model. + +```python +from pydantic import BaseModel, ConfigDict +from uuid import UUID + +class UserCreate(BaseModel): + model_config = ConfigDict(extra='forbid') + email: str + name: str + +class User(UserCreate): + id: UUID +``` + +## Pydantic and Schema Fidelity (C1 Correctness, API P1 Contract Fidelity, Data P7 Type Fidelity) + +- **Pydantic models are the API contract:** a FastAPI handler taking `UserCreate` rejects malformed JSON with a 422 before the body runs. This is `api/P1` (contract fidelity) at the type boundary. +- **`ConfigDict(extra='forbid')` rejects unknown fields:** silently accepting extras is a contract leak — the server appears to handle fields it ignores. +- **Validators raise `ValueError`, not `Exception`:** Pydantic converts `ValueError` to a validation error response; a generic `Exception` becomes a 500 and hides the input bug. +- **Applies `api/P1`:** the model is the source of truth; the OpenAPI schema is generated from it, not hand-written. Drift between schema and code is impossible. + +```python +from typing import Annotated +from pydantic import BaseModel, Field, StringConstraints + +EmailStr = Annotated[str, StringConstraints(pattern=r'^[^@\s]+@[^@\s]+$')] + +class Login(BaseModel): + email: EmailStr + password: Annotated[str, Field(min_length=8)] +``` + +## Errors as Data (Errors P1 Errors are Data, C1 Correctness) + +- **`Union[T, Error]` over `Optional[T]` for expected failures:** `Optional[User]` cannot distinguish "not found" from "permission denied". A discriminated `Result` carries the cause. +- **Custom exception hierarchy rooted at `AppError`:** `class NotFoundError(AppError)` etc. — callers can `except AppError` for the broad case, or a specific subclass for handling. +- **`raise` for exceptional paths, `return Result` for expected:** "user not found" is expected (a `Result`); "DB connection lost" is exceptional (a `raise`). Conflating them makes error handling a guess. +- **Applies `errors/P1`:** errors are values, not control-flow magic. A `Result` type encodes this at the type level even where exceptions are the runtime mechanism. + +```python +from dataclasses import dataclass +from typing import Generic, TypeVar, Union + +T = TypeVar('T') + +@dataclass(frozen=True) +class Ok(Generic[T]): + value: T + +@dataclass(frozen=True) +class Err: + error: Exception + +Result = Union[Ok[T], Err] + +def find_user(id: UUID) -> Result[User]: + row = db.get(id) + if row is None: + return Err(NotFoundError(f'user {id}')) + return Ok(User.from_row(row)) +``` + +## Generics and Protocols (C6 Composability, C1 Correctness) + +- **`Protocol` for structural typing (PEP 544):** a `Repository` protocol defines `get`/`save` without requiring an inheritance hierarchy; any class matching the shape satisfies it. +- **`TypeVar` with bounds for generic functions:** `T = TypeVar('T', bound=Entity)` lets `serialize(t: T) -> dict` access `t.id`. +- **`Generic[T]` for container types:** a typed `Repository[T]` preserves the element type across `get`/`save`, rather than widening to `Any`. +- **`@overload` for callable overloads:** `def parse(s: str) -> int: ...` vs `def parse(s: bytes) -> int: ...` — the runtime body is one function; the overloads are the type contract. + +```python +from typing import Protocol, TypeVar + +T = TypeVar('T') + +class Repository(Protocol[T]): + def get(self, id: str) -> T | None: ... + def save(self, t: T) -> None: ... + +class UserRepo: + def get(self, id: str) -> User | None: ... + def save(self, u: User) -> None: ... + +def use_repo(r: Repository[User]) -> None: + u = r.get('abc') # type: User | None +``` + +## Cross-References + +- `domains/data/schema-design.md` — Pydantic models parallel schema design at the TS/JSON boundary. +- `domains/data/first-principles.md` — Data P7 Type Fidelity is the primary trace for this doc. +- `domains/api/rest.md` — contract fidelity for FastAPI handlers consuming Pydantic models. +- `domains/errors/patterns.md` — the `Result` discriminated union as error-as-data encoding. +- `languages/py-async.md` — typed async results built on the `Result` union here. +- `languages/py-tooling.md` — the `mypy`/`pyright` config that enforces these hints. \ No newline at end of file diff --git a/languages/python.md b/languages/python.md index 9df98ce..95d8368 100644 --- a/languages/python.md +++ b/languages/python.md @@ -2,6 +2,13 @@ > How Atelier's domain principles apply in Python specifically. Derives from `domains/` docs. +## Derived Docs + +- [py-types.md](py-types.md) — type hints + Pydantic, mypy/pyright, gradual typing. +- [py-tooling.md](py-tooling.md) — ruff, mypy, poetry, uv, virtualenv discipline. +- [py-async.md](py-async.md) — asyncio, anyio, cancellation, structured concurrency. +- [py-testing.md](py-testing.md) — pytest, factory_boy, fixture discipline, parametrize. + ## Type System (C1 Correctness, Data P7 Type Fidelity) - **Type hints on every function:** `def get_user(id: UUID) -> User | None:`. diff --git a/languages/rs-async.md b/languages/rs-async.md new file mode 100644 index 0000000..9045d21 --- /dev/null +++ b/languages/rs-async.md @@ -0,0 +1,129 @@ +# Rust Async — Derived Application + +> Applies Atelier's domain principles to Rust async specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/rust.md` for the language first-principles stub. + +## tokio and the Async Runtime (Concurrency P5 Lock Minimization, C6 Composability) + +- **`tokio` is the default async runtime:** `#[tokio::main]` for the entry; `tokio::spawn` for a task. The runtime owns the reactor, the I/O driver, and the timer. +- **`tokio::spawn` returns a `JoinHandle` like `std::thread::spawn`:** a dropped `JoinHandle` detaches (the task keeps running); `await` the handle to join. Prefer await to detach. +- **`tokio::task::JoinSet` for structured concurrency:** a set of tasks awaited together; on drop, all remaining tasks are cancelled. Mirrors `errgroup`/`TaskGroup` semantics. +- **`runtime` features are explicit:** `tokio = { version = "1", features = ["full"] }` for a binary; `["rt", "rt-multi-thread", "macros"]` for a library. Pulling `full` into a library bloats downstream. + +```rust +#[tokio::main] +async fn main() { + let mut set = tokio::task::JoinSet::new(); + for id in ["a", "b", "c"] { + set.spawn(fetch_user(id.to_string())); + } + while let Some(res) = set.join_next().await { + match res { + Ok(Ok(u)) => println!("{}", u.name), + Ok(Err(e)) => eprintln!("err: {e}"), + Err(join_err) => eprintln!("panic: {join_err}"), + } + } +} +``` + +## Async Traits (Concurrency P7 Cancellation Support, C6 Composability) + +- **`async fn` in traits stabilized in Rust 1.75:** `trait Repo { async fn get(&self, id: &str) -> Result; }`. No `async-trait` crate needed for new code on recent toolchains. +- **`Box` with async methods needs `dyn`-compatibility:** the returned future is `Pin>`; the compiler boxes it. For hot paths, use generics (`impl Trait`) over `dyn`. +- **`async-trait` crate for older toolchains:** macro that desugars to a `Pin>`. Migrate to native `async fn in trait` when the toolchain allows. +- **`Send` bounds on async traits for cross-thread spawn:** `trait Repo: Send { async fn get(&self, id: &str) -> Result; }` — the returned future must be `Send` to spawn on a multi-thread runtime. + +```rust +trait UserRepo: Send + Sync { + async fn get(&self, id: &str) -> Result; +} + +struct PgRepo { pool: PgPool } +impl UserRepo for PgRepo { + async fn get(&self, id: &str) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id).fetch_one(&self.pool).await.map_err(Error::from) + } +} +``` + +## Cancellation (Concurrency P7 Cancellation Support, Concurrency P8 Timeout Discipline) + +- **Cancellation is cooperative via dropping the future:** `tokio::select!` drops the unselected branch, cancelling it. A dropped future stops at its next `.await` point. +- **`tokio::time::timeout` for a deadline:** `timeout(Duration::from_secs(5), op).await` returns `Ok(Ok(v))` on success, `Ok(Err(e))` on inner error, `Err(Elapsed)` on timeout. Every external `await` races against a deadline (Concurrency P8). +- **`tokio::select!` for cancel-aware waits:** `select! { res = op => res, _ = cancel => return Err(Cancelled), }`. The unselected branch is dropped, cancelling it. +- **Cancellation is not atomic:** a future dropped mid-`await` may have partial state. `Drop` runs on cancellation; clean up there (e.g., rollback a transaction). +- **Applies `concurrency/P7`:** cancellation is a first-class signal; the runtime propagates it via drop. No `CancelledError` to catch — the future is gone. + +```rust +use tokio::time::timeout; +use std::time::Duration; + +async fn fetch_with_timeout(url: &str) -> Result { + match timeout(Duration::from_secs(5), fetch(url)).await { + Ok(Ok(r)) => Ok(r), + Ok(Err(e)) => Err(e.into()), + Err(_elapsed) => Err(Error::Timeout), + } +} + +async fn cancellable(op: impl Future, mut cancel: tokio::sync::oneshot::Receiver<()>) { + tokio::select! { + _ = op => {}, + _ = &mut cancel => println!("cancelled"), + } +} +``` + +## Pin and Self-Referential Futures (Concurrency P5 Lock Minimization, C1 Correctness) + +- **`async fn` returns a `Future` that is often self-referential:** the generated state machine may hold a borrow into its own stack. Such a future must be `Pin`ned to move safely. +- **`Pin>` to box and pin:** `Box::pin(async { ... })` returns a `Pin>`. The cost is a heap alloc; the win is `Send`/`dyn`-compatibility. +- **`Pin<&mut T>` for in-place polling:** `Pin::new(&mut fut)` pins a stack future; the borrow checker prevents moving it. Use for stack-allocated futures in `select!`. +- **Do not `unsafe` unpin:** `Pin::get_unchecked_mut` opts out of the pin guarantees. Application code never needs it; library code uses it for `poll` implementations. + +```rust +use std::pin::Pin; + +async fn boxed() -> Pin + Send>> { + Box::pin(async { + // self-referential state machine is safe to move once pinned + }) +} +``` + +## Bounded Channels and Backpressure (Concurrency P9 Bounded Queues) + +- **`tokio::sync::mpsc::channel(N)` is bounded:** `send().await` blocks when full (backpressure, Concurrency P9). Unbounded `unbounded_channel()` lets the producer run ahead and OOM. +- **`tokio::sync::mpsc::Sender::try_send` for non-blocking send:** returns `Err(TrySendError::Full(v))` when full; the caller decides to drop, log, or back off. A bounded queue + `try_send` is the backpressure-aware pattern. +- **`tokio::sync::broadcast` for fan-out:** multiple receivers each get a copy; a slow receiver misses (lag). Use for telemetry, not for commands. +- **Applies `messaging/queues`:** a bounded tokio channel is an in-process broker — bounded buffer, backpressure, at-most-once handoff. The same semantics apply; the broker is local. + +```rust +use tokio::sync::mpsc; + +async fn producer(tx: mpsc::Sender) { + for j in jobs() { + if tx.send(j).await.is_err() { return; } // receiver dropped + } +} + +async fn consumer(rx: mpsc::Receiver) { + while let Some(j) = rx.recv().await { + process(j).await; + } +} + +let (tx, rx) = mpsc::channel::(16); // bounded: backpressure +``` + +## Cross-References + +- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here. +- `domains/concurrency/first-principles.md` — Concurrency P5 Lock Minimization, P7 Cancellation Support, P8 Timeout Discipline, P9 Bounded Queues. +- `domains/messaging/delivery-semantics.md` — at-most-once vs at-least-once framing for async retry/cancel (IDEATE-40). +- `languages/rs-ownership.md` — `Send`/`Sync` bounds on futures build on the ownership model here. +- `languages/rs-tooling.md` — `tokio` feature flags and the `cargo` build profiles detailed there. +- `languages/rs-testing.md` — `#[tokio::test]` and async test patterns. \ No newline at end of file diff --git a/languages/rs-ownership.md b/languages/rs-ownership.md new file mode 100644 index 0000000..dd5f708 --- /dev/null +++ b/languages/rs-ownership.md @@ -0,0 +1,136 @@ +# Rust Ownership — Derived Application + +> Applies Atelier's domain principles to Rust's ownership model specifically. Rust's distinctive strength (Send/Sync, lifetimes, borrowing) earns a dedicated ownership doc rather than an `rs-types.md`. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/rust.md` for the language first-principles stub. + +## Ownership and Move Semantics (Concurrency P1 Immutability by Default, C1 Correctness) + +- **Ownership is unique:** at any time, exactly one owner holds a value. Assignment passes ownership (`let y = x;` — `x` is moved, not copied). The compiler rejects use-after-move. +- **`Copy` types (integers, `bool`, `&T`) duplicate on assignment; everything else moves.** A `struct` is `Copy` only if all fields are; opt in via `#[derive(Copy, Clone)]` only for small, cheap-to-copy types. +- **Pass by `&T` for read-only, `&mut T` for mutation:** a borrow does not transfer ownership; the caller retains the value after the callee returns. +- **Applies `concurrency/P1` (immutability by default):** `&T` is shared and immutable; `&mut T` is exclusive and mutable. The compiler enforces "one or many, never both" — aliasing XOR mutation, statically. + +```rust +let s = String::from("hello"); +let t = s; // s moved into t +// println!("{}", s); // error: use of moved value + +let n = 5; +let m = n; // i32 is Copy: n still usable +println!("{} {}", n, m); +``` + +## Borrowing and Lifetimes (C1 Correctness, Data P7 Type Fidelity, Concurrency P3 Boundaries are Locks) + +- **`&'a T` ties a borrow to a lifetime `'a`:** the borrow cannot outlive the owner. Lifetimes are static — the compiler rejects dangling references. +- **Lifetime elision when unambiguous:** `fn first<'a>(s: &'a str) -> &'a str` is elided to `fn first(s: &str) -> &str` (one input → output lifetime). When ambiguous, name the lifetime. +- **`'static` is the longest lifetime (the whole program):** not "until I drop it." Use `'static` only for values that genuinely live forever (string literals, `const`s); leaking to `'static` to satisfy the checker is a bug. +- **`Ref<'a, T>` and `RefMut<'a, T>` from `RefCell` are runtime-checked borrows:** the borrow rules still apply, checked at runtime instead of compile time. A second `RefMut` panics. +- **Applies `concurrency/P3` (boundaries are locks):** `&mut T` is the compile-time lock — exclusive access is the boundary; no runtime mutex needed for single-threaded aliasing discipline. + +```rust +fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { + if a.len() > b.len() { a } else { b } // borrow tied to both inputs +} + +fn dangling() -> &str { // compile error: missing lifetime + let s = String::from("local"); + &s // error: s drops at end of fn +} +``` + +## Send and Sync (Concurrency P1 Immutability by Default, Concurrency P3 Boundaries are Locks, C1 Correctness) + +- **`Send`:** a type `T: Send` may be moved across thread boundaries. Most types are `Send`; `Rc` is not (shared non-atomically refcounted). +- **`Sync`:** a type `T: Sync` may be shared (`&T`) across threads. `RefCell` is `!Sync` (interior mutability without atomics); `Mutex` is `Sync` (it synchronizes). +- **The compiler enforces `Send`/`Sync` at the thread-spawn boundary:** `std::thread::spawn(move || { ... })` requires the closure's captures to be `Send`. +- **Applies `concurrency/P1` and `concurrency/P3`:** `Send` is the move-across-boundary contract; `Sync` is the share-across-boundary contract. Data races are a compile error, not a runtime detector. This is Rust's distinctive strength over Go's race detector. + +```rust +use std::rc::Rc; +use std::sync::Arc; + +let rc = Rc::new(5); +// std::thread::spawn(move || { println!("{}", rc) }); // error: Rc is !Send + +let arc = Arc::new(5); +std::thread::spawn(move || { println!("{}", arc) }); // ok: Arc is Send+Sync +``` + +## Shared Mutation: Arc, Mutex, RwLock (Concurrency P3 Boundaries are Locks, Concurrency P5 Lock Minimization) + +- **`Arc` for shared ownership across threads:** atomic refcounted. Clone increases the count; the last drop frees `T`. +- **`Mutex` for exclusive mutation across threads:** `lock()` blocks until exclusive; the guard `MutexGuard` derefs to `&mut T` and releases on drop. +- **`RwLock` for read-heavy, `Mutex` for write-heavy:** RwLock allows multiple readers or one writer. For most cases, `Mutex` is simpler and faster; prefer it unless reads dominate by 10x+. +- **Hold the lock for the smallest scope:** `let g = m.lock().unwrap();` then drop `g` before I/O. RAII releases on scope exit; explicit `drop(g)` clarifies intent. +- **Applies `concurrency/P5` (lock minimization):** prefer message passing (`mpsc` channels) over locks. When a lock is needed, scope it minimally. + +```rust +use std::sync::{Arc, Mutex}; +use std::thread; + +let counter = Arc::new(Mutex::new(0)); +let mut handles = vec![]; +for _ in 0..10 { + let c = Arc::clone(&counter); + handles.push(thread::spawn(move || { + let mut g = c.lock().unwrap(); + *g += 1; + // g drops here, lock released + })); +} +for h in handles { h.join().unwrap(); } +println!("{}", *counter.lock().unwrap()); +``` + +## Interior Mutability (Concurrency P1 Immutability by Default, C1 Correctness) + +- **`Cell` for `Copy` types, `RefCell` for non-`Copy`:** interior mutability moves the borrow check from compile time to runtime. `RefCell::borrow_mut()` panics on a second mutable borrow. +- **`Mutex`/`RwLock` for thread-safe interior mutability:** the runtime check is the lock, not a panic. Use these across threads; `RefCell` only single-threaded. +- **`UnsafeCell` is the primitive; never use directly:** `Cell`, `RefCell`, `Mutex` are safe wrappers. Direct `UnsafeCell` is `unsafe` and opts out of the aliasing guarantee. +- **Applies `concurrency/P1`:** interior mutability is the exception, not the default. Reach for it when an API must present `&self` while mutating internally (e.g., a cache); document why. + +```rust +use std::cell::RefCell; + +struct Cache { + inner: RefCell>, +} +impl Cache { + fn get(&self, id: &str) -> Option { + // &self (immutable) but mutates internally + self.inner.borrow_mut().entry(id.to_string()).or_insert_with(|| fetch()).clone() + } +} +``` + +## Drop and RAII (C1 Correctness, Concurrency P3 Boundaries are Locks) + +- **`Drop` runs when the owner goes out of scope:** no `defer`, no `finally`. A `MutexGuard` releases, a `File` closes, a `JoinHandle`... does not join (a dropped `JoinHandle` detaches). +- **`Drop` is deterministic:** it runs at scope exit, not GC time. This is why `Arc`'s refcount is precise and `Mutex` release is timely. +- **`ManuallyDrop` to opt out:** for FFI types whose destructor you must call manually. Rare in application code; common in `unsafe` bindings. +- **`Drop` order: fields in declaration order, then the struct itself.** A field that another field's `Drop` depends on must be declared last. + +```rust +struct Resource { name: String } +impl Drop for Resource { + fn drop(&mut self) { + println!("dropping {}", self.name); // runs at scope end + } +} + +fn main() { + let _r = Resource { name: "x".into() }; + // _r drops here, prints "dropping x" +} +``` + +## Cross-References + +- `domains/concurrency/first-principles.md` — Concurrency P1 Immutability, P3 Boundaries are Locks, P5 Lock Minimization. +- `domains/data/first-principles.md` — Data P7 Type Fidelity (lifetimes are the type-level fidelity for references). +- `domains/concurrency/patterns.md` — message-passing vs lock patterns applied via `Arc`/`Mutex`/`mpsc`. +- `domains/errors/patterns.md` — `?` propagation relies on ownership transfer of the error. +- `languages/rs-async.md` — async borrows (`Pin`/`&mut`) build on the lifetime model here. +- `languages/rs-testing.md` — `Send`/`Sync` tests and ownership-based property tests. \ No newline at end of file diff --git a/languages/rs-testing.md b/languages/rs-testing.md new file mode 100644 index 0000000..ae0f4ff --- /dev/null +++ b/languages/rs-testing.md @@ -0,0 +1,159 @@ +# Rust Testing — Derived Application + +> Applies Atelier's domain principles to Rust testing specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/rust.md` for the language first-principles stub. + +## #[test] and Co-located Tests (Testing P1 Tests as Specification, C2 Clarity) + +- **`#[test]` on functions in a `#[cfg(test)] mod tests` block:** tests co-located with source, compiled only in `cargo test`. A test file far from its subject rots (Documentation P5 Discoverability). +- **Test names read as a spec:** `fn create_user_rejects_invalid_email()` — a reader understands the unit from the name. Avoid `fn test_user_1()`. +- **`assert!` / `assert_eq!` / `assert_ne!` over raw `panic!`:** the macros produce readable failure output (`assertion failed: left == right, left: 5, right: 3`). Raw `panic!` gives a message only (Testing P6 Failure Specificity). +- **Applies `Testing P1`:** the test is a specification; the failure message is the spec violation. + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_user_rejects_invalid_email() { + let r = create_user("not-an-email"); + assert!(matches!(r, Err(Error::Validation(_)))); + } + + #[test] + fn create_user_returns_persisted_id() { + let u = create_user("a@b.co").unwrap(); + assert!(!u.id.is_empty()); + } +} +``` + +## proptest and Property Tests (Testing P9 Edge Case Coverage, Testing P1 Tests as Specification) + +- **`proptest` (or `quickcheck`) for invariant tests:** declare a property (`parse(serialize(x)) == x`), the framework generates hundreds of inputs and shrinks failures to a minimal counterexample (Testing P9). +- **Strategy over hand-written generators:** `proptest::collection::vec(any::(), 0..100)` generates arbitrary `Vec`; do not hand-roll a generator for each property. +- **`proptest!` macro or `proptest! { ... }` block:** each `case (name) => { ... }` is a property. The block is the spec (Testing P1). +- **Property tests complement, not replace, example tests:** examples document the happy path; properties cover the edge space. Both are required. + +```rust +use proptest::prelude::*; + +proptest! { + #[test] + fn roundtrips_id(s in "[a-z0-9]{1,32}") { + let id = UserId::new(&s).unwrap(); + assert_eq!(id.as_str(), s); + } + + #[test] + fn rejects_invalid_id(s in "[^a-z0-9]+") { + assert!(UserId::new(&s).is_err()); + } +} +``` + +## Mock Discipline (Testing P2 Independence, Testing P7 Realism) + +- **Mock at the trait, not the struct:** `trait Store { fn get(&self, id: &str) -> Result; }` in production; `#[automock] trait Store` (via `mockall`) in test. The trait is the contract. +- **`mockall` for generated mocks:** `#[automock] trait Repo {}` generates `MockRepo` with `expect_*` methods. Each expectation is per-test; no shared mock state (Testing P2 Independence). +- **Mock the boundary, not the unit:** mock `Repo`, not `UserService` (the unit). Mocking the unit under test tests the mock (Testing P7 realism). +- **No `#[cfg(test)]` on production code paths to inject mocks:** instead, accept the trait as a generic or `dyn` parameter. Test-only branches in production code are dead code in prod. + +```rust +use mockall::*; + +#[automock] +trait UserRepo { + fn get(&self, id: &str) -> Result; +} + +#[test] +fn get_user_returns_not_found() { + let mut repo = MockUserRepo::new(); + repo.expect_get() + .with(eq("abc")) + .returning(|_| Err(Error::NotFound)); + let svc = UserService::new(Box::new(repo)); + assert!(matches!(svc.get_user("abc"), Err(Error::NotFound))); +} +``` + +## Async Tests (Concurrency P10 Test for Race Conditions, Testing P1 Tests as Specification) + +- **`#[tokio::test]` for `async fn` tests:** runs the coroutine on a tokio runtime. Without it, an `async fn` test returns a future, never awaited (silently passes). +- **`#[tokio::test(flavor = "multi_thread")]` for concurrency-sensitive tests:** multi-thread runtime surfaces races that single-thread misses (Concurrency P10). +- **`tokio::time::pause()` and `advance()` for time:** freeze and advance the runtime clock deterministically. No `tokio::time::sleep(real)` in tests. +- **Race-sensitive tests use `loom` for model-checking:** `loom` simulates all thread interleavings; it catches races `-race`-style detectors miss. Use for lock-free data structures. + +```rust +#[tokio::test] +async fn async_fetch_returns_user() { + let u = fetch_user("abc").await.unwrap(); + assert!(!u.name.is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_cache_is_safe() { + let c = Arc::new(Cache::new()); + let mut h = vec![]; + for i in 0..10 { + let c = c.clone(); + h.push(tokio::spawn(async move { c.get(&i.to_string()).await; })); + } + for x in h { x.await.unwrap(); } +} +``` + +## Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage) + +- **No `SystemTime::now()` or `Instant::now()` in code under test:** inject a `Clock` trait. In tests, a fake clock advances deterministically. +- **`tokio::time::pause()` for async time:** freezes the runtime clock; `tokio::time::advance(dur)` moves it. A `sleep(5s)` in test resolves instantly. +- **`--test-threads=1` to reproduce order coupling:** by default, `cargo test` runs tests in parallel; a test that passes alone but fails in a suite has hidden shared state. `-1` reproduces. + +```rust +trait Clock { fn now(&self) -> std::time::Instant; } + +struct FakeClock(std::time::Instant); +impl Clock for FakeClock { + fn now(&self) -> std::time::Instant { self.0 } +} + +#[test] +fn user_has_created_at() { + let clk = FakeClock(std::time::Instant::now()); + let u = create_user_with_clock("a@b.co", &clk).unwrap(); + assert_eq!(u.created_at, clk.now()); +} +``` + +## Doc Tests (Documentation P1 Documentation is Code, Testing P1 Tests as Specification) + +- **`cargo test --doc` runs `///` fenced blocks:** a `///` example with `#`-hidden setup is a tested artifact; a stale output fails the build (Documentation P1). +- **`no_run` for examples that should compile but not run:** ```` ```rust,no_run ```` — type-checks the example without executing. Use for examples that need a DB. +- **`ignore` for examples that should not compile-check:** ```` ```rust,ignore ```` — skips entirely. Rare; prefer `no_run`. +- **Applies `Testing P1`:** the doc example is the spec; the doc test is the spec's regression test. + +```rust +/// Fetch a user by id. +/// +/// # Example +/// +/// ``` +/// # use mycrate::{get_user, Error}; +/// let u = get_user("abc").unwrap(); +/// assert!(!u.name.is_empty()); +/// ``` +pub fn get_user(id: &str) -> Result { /* ... */ } +``` + +## Cross-References + +- `domains/testing/pyramid.md` — where unit/property/doc tests sit; proptest is the property layer. +- `domains/testing/fixtures.md` — `t.Cleanup`-equivalent (`Drop` in tests) as fixture discipline. +- `domains/testing/first-principles.md` — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage. +- `domains/concurrency/first-principles.md` — Concurrency P10 (test for races), `loom` model-checking. +- `languages/rs-ownership.md` — `Send`/`Sync` tests and ownership-based property tests. +- `languages/rs-async.md` — `#[tokio::test]` patterns from that doc. +- `languages/rs-tooling.md` — `cargo test` flags (`--doc`, `--test-threads`) detailed here. \ No newline at end of file diff --git a/languages/rs-tooling.md b/languages/rs-tooling.md new file mode 100644 index 0000000..e7b0d05 --- /dev/null +++ b/languages/rs-tooling.md @@ -0,0 +1,95 @@ +# Rust Tooling — Derived Application + +> Applies Atelier's domain principles to Rust tooling specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/rust.md` for the language first-principles stub. + +## cargo and Build Discipline (DevOps P2 Automation, DevOps P1 Reproducibility) + +- **`cargo build` for dev, `cargo build --release` for release:** release enables optimizations (LTO, codegen-units=1). The default profile is for fast iteration, not perf. +- **`Cargo.lock` committed for applications and CI:** for libraries, commit the lock for CI reproducibility even though consumers resolve their own tree. A drifted lock breaks reproducibility (DevOps P1). +- **`cargo update` periodically, with a CI check:** `cargo update` bumps patch versions in the lock; a CI job that fails on lock drift catches a forgotten `cargo update`. +- **`cargo vendor` for hermetic CI:** vendors `vendor/` into the repo; CI builds without network. The trade-off is repo size; the win is reproducibility. + +```toml +# Cargo.toml — profile discipline +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" # smaller binary, no unwinding +``` + +## clippy (DevOps P2 Automation, C2 Clarity) + +- **`cargo clippy` is the lint layer over `rustc`:** it catches `clone()` where a borrow would do, `unwrap()` in library code, and needless `Box`. Run on every build. +- **`cargo clippy -- -D warnings` in CI:** warnings are errors. A clippy warning is a smell; accumulating them erodes the signal (Clarity C2). +- **Per-lint allow only with a tracked reason:** `#[allow(clippy::needless_collect)] // reason: GH-123 — collect needed for len` — each allow links to a ticket. Untracked allows accumulate into a permanently lint-bypassed core. +- **`cargo clippy --fix` for safe auto-fixes:** applies the linter's suggested change. Review the diff; do not run blindly on a large commit. + +```bash +# CI gate +cargo clippy --all-targets --all-features -- -D warnings +``` + +## cargo fmt (DevOps P2 Automation, C2 Clarity) + +- **`cargo fmt` is the formatter; format is not debated in review:** run in CI as a check (`cargo fmt --check`), not a fix. A failing check blocks the PR. +- **`rustfmt.toml` for repo-wide settings:** if the defaults are wrong for the repo, override once and stop. Do not relitigate per-PR. +- **Applies `devops/P2`:** the format gate is automated; a reviewer never comments on style. + +```bash +# CI gate — fail if unformatted +cargo fmt --check +``` + +## Edition Discipline (DevOps P1 Reproducibility, C5 Reversibility) + +- **`edition` in `Cargo.toml` pins the language edition:** 2015, 2018, 2021, 2024. An edition is a coherent set of language changes; bumping it is a deliberate migration. +- **Edition is not the compiler version:** `rustc 1.75` supports edition 2021; edition 2024 needs a newer `rustc`. Pin the toolchain with `rust-toolchain.toml`. +- **Bump editions deliberately, not opportunistically:** `cargo fix --edition` applies the migration lint; review the diff. A bump mid-feature conflates two changes. +- **Applies `devops/P1` and `C5` (reversibility):** pinning the edition and toolchain makes the build reproducible; bumping is a controlled, reversible change. + +```toml +# Cargo.toml +[package] +edition = "2021" +rust-version = "1.75" +``` + +```toml +# rust-toolchain.toml +[toolchain] +channel = "1.75" +components = ["clippy", "rustfmt"] +``` + +## Documentation in the Pipeline (Documentation P1 Documentation is Code, DevOps P9 Documentation in the Pipeline) + +- **`cargo doc` from doc comments:** `///` on items generates API docs; `cargo doc --open` previews. The build fails on broken intra-doc links (`#![warn(rustdoc::broken_intra_doc_links)]`). +- **Doc tests are run by `cargo test`:** a `///` fenced block with `#`-hidden setup is a tested artifact; a stale example fails `cargo test --doc` (Documentation P1). +- **`#![warn(missing_docs)]` for libraries:** public items without doc comments fail the build. Documentation is a build gate, not an afterthought. +- **`cargo readme` or `cargo docs-rs` for landing pages:** the crate's `README.md` is rendered on docs.rs; keep it in sync with `lib.rs`'s top-level doc. + +```rust +#![warn(missing_docs, rustdoc::broken_intra_doc_links)] + +/// Fetch a user by id. +/// +/// # Example +/// +/// ``` +/// # use mycrate::get_user; +/// let u = get_user("abc").unwrap(); +/// println!("{}", u.name); +/// ``` +pub fn get_user(id: &str) -> Result { /* ... */ } +``` + +## Cross-References + +- `domains/devops/ci-cd.md` — the pipeline gates that host clippy/fmt/test. +- `domains/devops/first-principles.md` — DevOps P1 Reproducibility, P2 Automation. +- `domains/documentation/first-principles.md` — Documentation P1 Documentation is Code. +- `languages/rs-ownership.md` — `Send`/`Sync` clippy lints reference this doc. +- `languages/rs-async.md` — async-runtime tooling (`tokio` features) detailed here. +- `languages/rs-testing.md` — `cargo test` flags (`--doc`, `--no-run`) detailed here. \ No newline at end of file diff --git a/languages/rust.md b/languages/rust.md index 153aad0..82fe34e 100644 --- a/languages/rust.md +++ b/languages/rust.md @@ -2,6 +2,13 @@ > How Atelier's domain principles apply in Rust specifically. Derives from `domains/` docs. +## Derived Docs + +- [rs-ownership.md](rs-ownership.md) — Send/Sync, lifetimes, borrowing, ownership transfer. +- [rs-tooling.md](rs-tooling.md) — cargo, clippy, fmt, edition discipline. +- [rs-async.md](rs-async.md) — tokio, async traits, cancellation, pin. +- [rs-testing.md](rs-testing.md) — #[test], proptest, property testing, mock discipline. + ## Type System (C1 Correctness, Data P7 Type Fidelity) - **Newtypes for domain concepts:** `struct UserId(String);` — zero-cost, type-safe. diff --git a/languages/ts-async.md b/languages/ts-async.md new file mode 100644 index 0000000..989b4d9 --- /dev/null +++ b/languages/ts-async.md @@ -0,0 +1,115 @@ +# TypeScript Async — Derived Application + +> Applies Atelier's domain principles to TypeScript async specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/typescript.md` for the language first-principles stub. + +## Promises and AbortSignal (Concurrency P7 Cancellation Support, C1 Correctness) + +- **Every async function accepts an optional `AbortSignal`:** cancellation is a first-class parameter, not a side channel. The signal propagates to `fetch`, `setTimeout`, and downstream awaits. +- **`AbortController` is the producer side; `AbortSignal` is the consumer side:** a function takes a `signal` (read-only), the caller owns the `controller` and decides when to abort. +- **Abort propagates as a rejected `Promise`:** `fetch` rejects with `AbortError`; downstream code sees the rejection, not a silent no-op. This preserves `errors/P5` (recoverable when possible) — the caller can distinguish cancellation from a real failure. +- **Applies `concurrency/P7`:** no async operation runs without a path to cancel it. A long-running `await` with no signal is a hung request. +- **Never swallow `AbortError`:** re-throw or handle distinctly; cancellation is the caller's intent, not an error to log. + +```typescript +async function fetchUser(id: UserId, signal?: AbortSignal): Promise { + const ctrl = new AbortController(); + signal?.addEventListener('abort', () => ctrl.abort()); + const res = await fetch(`/users/${id}`, { signal: ctrl.signal }); + if (!res.ok) throw new HttpError(res.status); + return res.json() as Promise; +} + +// caller controls cancellation +const ctrl = new AbortController(); +const timer = setTimeout(() => ctrl.abort(), 5000); +try { + const u = await fetchUser(id, ctrl.signal); +} finally { + clearTimeout(timer); +} +``` + +## async/await Discipline (Concurrency P8 Timeout Discipline, C2 Clarity) + +- **`await` is the only async primitive in application code:** no `.then` chains, no callback pyramids. `async`/`await` reads top-to-bottom (Clarity C2). +- **Never `await` in a hot loop without batching:** sequential `await` in a `for` loop is O(n) latency. Use `Promise.all` for parallelism; `for await...of` only for genuine streams. +- **`Promise.race` for a timeout:** every external `await` has a deadline. `Promise.race([op, timeout])` rejects when the deadline passes. +- **`return` vs `return await`:** inside `try`/`finally`, `return await` runs the `finally`; bare `return` of a Promise defers the `finally` to the microtask. Prefer `return await` when cleanup must run. +- **Applies `concurrency/P8`:** a bare `await` with no timeout is an unbounded wait. External calls (network, disk) always race against a deadline. + +```typescript +async function fetchWithTimeout(url: string, ms = 5000, signal?: AbortSignal): Promise { + const ctrl = new AbortController(); + signal?.addEventListener('abort', () => ctrl.abort()); + const timer = new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError(ms)), ms) + ); + try { + return await Promise.race([fetch(url, { signal: ctrl.signal }), timer]); + } finally { + clearTimeout(timer); // cleanup runs on success and on race-loss + } +} +``` + +## Error Handling in Async (Errors P5 Recoverable When Possible, Errors P1 Errors are Data) + +- **Catch `unknown`, narrow with a type guard:** `catch (e: unknown)` — TS does not infer the error type. `instanceof` or a discriminator narrows it. +- **Retry with backoff for transient failures:** network blips are recoverable (Errors P5). Exponential backoff with jitter, capped retry count, and an `AbortSignal`-aware `setTimeout`. +- **No retry for non-idempotent operations:** a `POST` that creates a resource is not safely retryable without an idempotency key (applies `api/P6` Idempotency). +- **Typed errors over `Error` subclasses:** a discriminated union `AppError = Network | Timeout | Cancelled` carries context (Errors P4 Preserve Context) without `instanceof` chains. + +```typescript +async function fetchRetry(url: string, attempts = 3, signal?: AbortSignal): Promise { + for (let i = 0; i < attempts; i++) { + try { + return await fetchWithTimeout(url, 5000, signal); + } catch (e: unknown) { + if (e instanceof AbortError) throw e; // do not retry cancellation + if (e instanceof TimeoutError && i < attempts - 1) { + await sleep(jitter(i), signal); // backoff before retry + continue; + } + throw e; + } + } + throw new Error('unreachable'); +} +``` + +## Cancellation Propagation (Concurrency P7 Cancellation Support, Concurrency P9 Bounded Queues) + +- **One signal, many consumers:** pass the same `AbortSignal` to every async call in a request. Aborting once cancels the whole tree. +- **Bounded concurrency with a semaphore:** a `Semaphore(N)` wrapping `Promise.all` caps in-flight requests (Concurrency P9 — bounded queues). Unbounded `Promise.all` on a 10k-item array exhausts file descriptors. +- **Cancellation is cooperative, not preemptive:** a long synchronous block inside an `async` function ignores the signal. Yield with `await Promise.resolve()` periodically in CPU-bound loops, or move to a worker. +- **Applies `messaging/delivery-semantics`:** a cancelable async operation is an at-most-once delivery — the caller may stop listening, the result may or may not arrive. Retry-on-cancel is at-least-once; the caller must declare which. + +```typescript +async function mapBounded(items: readonly T[], fn: (t: T, s: AbortSignal) => Promise, limit = 8, signal?: AbortSignal): Promise { + const ctrl = new AbortController(); + signal?.addEventListener('abort', () => ctrl.abort()); + const results: U[] = new Array(items.length); + let next = 0; + const workers = Array.from({ length: limit }, async () => { + while (true) { + const i = next++; + if (i >= items.length) break; + if (ctrl.signal.aborted) throw new AbortError(); + results[i] = await fn(items[i], ctrl.signal); + } + }); + await Promise.all(workers); + return results; +} +``` + +## Cross-References + +- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here. +- `domains/concurrency/first-principles.md` — Concurrency P7 Cancellation Support, P8 Timeout Discipline, P9 Bounded Queues. +- `domains/messaging/delivery-semantics.md` — at-most-once vs at-least-once framing for async retry/cancel (IDEATE-40). +- `domains/errors/patterns.md` — typed async errors and retry-with-backoff. +- `languages/ts-types.md` — `Result` and discriminated `AppError` used in async error handling. +- `languages/ts-tooling.md` — `no-floating-promises` lint rule that enforces these awaits. \ No newline at end of file diff --git a/languages/ts-testing.md b/languages/ts-testing.md new file mode 100644 index 0000000..16bd349 --- /dev/null +++ b/languages/ts-testing.md @@ -0,0 +1,117 @@ +# TypeScript Testing — Derived Application + +> Applies Atelier's domain principles to TypeScript testing specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/typescript.md` for the language first-principles stub. + +## Vitest and Jest (Testing P1 Tests as Specification, C2 Clarity) + +- **Vitest for new TS projects; Jest for legacy:** Vitest shares `vite`'s transform pipeline (no separate `ts-jest` config); Jest's ecosystem is broader. Either is acceptable — pick one per repo, do not mix. +- **Tests co-located with source:** `user.ts` → `user.test.ts`. A test file far from its subject rots (Documentation P5 Discoverability). +- **`describe`/`it` mirror the public API:** the test block names read as a specification ("User", "rejects an invalid email", "returns the persisted id"). A reader should understand the unit from test names alone (Testing P1). +- **`expect` over `assert`:** Vitest/Jest matchers produce readable failure output (`expect(x).toBe(y)` → "expected 5, received 3"). Raw `assert` gives a stack trace and nothing else (Testing P6 Failure Specificity). + +```typescript +// user.test.ts +import { describe, it, expect } from 'vitest'; +import { createUser } from './user'; + +describe('createUser', () => { + it('rejects an invalid email', async () => { + await expect(createUser({ email: 'not-an-email' })).rejects.toThrow(ValidationError); + }); + it('returns the persisted id', async () => { + const u = await createUser({ email: 'a@b.co' }); + expect(u.id).toMatch(/^[a-z0-9]+$/); + }); +}); +``` + +## Mock Discipline (Testing P2 Independence, Testing P7 Realism) + +- **Mock at the boundary, not the unit:** replace `fetch` or the DB client, not the function under test. Mocking the unit under test tests the mock, not the code (Testing P7 — realism). +- **No partial mocks of the system under test:** if a method must be stubbed, the unit is too large. Extract a collaborator and mock that. +- **Each test sets up and tears down its own state:** no shared mutable fixtures. A `beforeEach`/`afterEach` resets; a top-level `let` shared across tests is order-coupling (Testing P2 Independence). +- **`vi.useFakeTimers()` for time-dependent code:** never call `Date.now()` directly in code under test; inject a `Clock` port. In tests, fake timers make `setTimeout` synchronous. + +```typescript +import { vi, beforeEach, afterEach } from 'vitest'; + +beforeEach(() => { + vi.useFakeTimers(); + global.fetch = vi.fn(); // boundary mock +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); +``` + +## Type-Level Tests (Testing P1 Tests as Specification, Data P7 Type Fidelity) + +- **Type-level tests assert the type system, not runtime behavior:** `expectTypeOf().toMatchTypeOf` and `tsd`/`expect-type` fail the build when a type assertion is wrong. +- **Negative type tests are required:** `// @ts-expect-error` proves the compiler rejects what it should. A `@ts-expect-error` that no longer errors is itself an error (the comment must be consumed). +- **Branded types and utility types get type tests:** a `UserId` should not be assignable to `string`; a `Readonly` should not allow assignment. These invariants are part of the spec (Testing P1). +- **Applies `data/P7` (type fidelity):** a type-level test is a regression test for the type checker — if a refactor silently widens a type, the test fails. + +```typescript +import { expectTypeOf } from 'expect-type'; +import type { User, UserPatch, UserId } from './user'; + +test('UserPatch omits id and makes fields optional', () => { + expectTypeOf().toMatchTypeOf<{ name?: string; email?: string }>(); + expectTypeOf().not.toHaveProperty('id'); +}); + +test('UserId is not assignable to bare string', () => { + // @ts-expect-error — brand prevents widening + const s: string = {} as UserId; + expect(s).toBeDefined(); +}); +``` + +## Parametrize and Factories (Testing P3 Determinism, Testing P9 Edge Case Coverage) + +- **`it.each` / `test.each` for parametrized cases:** one table drives many runs; each row is an independent test with its own name and failure output. +- **Factories over fixtures:** `makeUser(overrides)` returns a fresh object per call. A shared `const user = {...}` across tests couples them and breaks determinism when one test mutates it (Testing P3). +- **Edge cases as rows, not special tests:** empty array, single element, max int, null, undefined — each a row in a `test.each` table. An ad-hoc `it('handles edge')` with multiple asserts hides which case failed (Testing P9 — edge case coverage, P6 failure specificity). +- **Property-style tests via `fast-check`:** for invariants (e.g., "parse(serialize(x)) === x"), `fast-check` generates hundreds of inputs and shrinks failures to a minimal counterexample. + +```typescript +import { test, expect } from 'vitest'; +import { makeUser } from './user.factory'; + +test.each([ + { input: '', reason: 'empty' }, + { input: 'a'.repeat(1000), reason: 'too long' }, + { input: 'not-an-email', reason: 'no @' }, +])('rejects email: $reason', async ({ input }) => { + await expect(makeUser({ email: input })).rejects.toThrow(ValidationError); +}); +``` + +## Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage) + +- **No `Date.now()`, `Math.random()`, or `crypto.randomUUID()` in code under test:** inject a `Clock`, `Random`, and `IdGen` port. In tests, provide deterministic fakes. +- **`--random` test order (Vitest `sequence.shuffle: true` default) catches order coupling:** a test that passes alone but fails in a suite has hidden state. The shuffle makes that state visible (Testing P2). +- **Race-detector parallelism for async tests:** run async tests concurrently by default; a test that assumes serial execution breaks under parallelism. Vitest's `concurrent` flag surfaces the bug. + +```typescript +import { vi, test, expect } from 'vitest'; + +test.concurrent('parallel fetch does not interleave state', async () => { + const store = new Store(); + await Promise.all([store.put('a', 1), store.put('b', 2)]); + expect(store.get('a')).toBe(1); + expect(store.get('b')).toBe(2); +}); +``` + +## Cross-References + +- `domains/testing/pyramid.md` — where unit/type/integration tests sit; the type-level tests here are the base layer. +- `domains/testing/fixtures.md` — factory-vs-fixture discipline applied via `makeUser`. +- `domains/testing/first-principles.md` — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage. +- `languages/ts-types.md` — the branded types and utility types that type-level tests assert. +- `languages/ts-async.md` — async tests use the cancellation/timeout patterns from that doc. +- `languages/ts-tooling.md` — `ts-jest`/`vitest` config and the `expect-type`/`tsd` toolchain. \ No newline at end of file diff --git a/languages/ts-tooling.md b/languages/ts-tooling.md new file mode 100644 index 0000000..e9cb35b --- /dev/null +++ b/languages/ts-tooling.md @@ -0,0 +1,114 @@ +# TypeScript Tooling — Derived Application + +> Applies Atelier's domain principles to TypeScript tooling specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/typescript.md` for the language first-principles stub. + +## tsc and tsconfig Discipline (DevOps P2 Automation, DevOps P1 Reproducibility) + +- **`strict: true` is the floor, not the ceiling:** it enables `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, and more. Disable sub-flags only with a justification comment. +- **`tsc --noEmit` in CI:** type-checking is a build gate; emission is the bundler's job. Separate the two so a type error fails CI even when the bundler would have succeeded. +- **`tsconfig` is per-project, not inherited verbatim:** a shared base (`extends`) encodes org defaults; each project overrides the deltas it needs. Avoids the "one monoreto-config-fits-all" trap. +- **`noUncheckedIndexedAccess` for safety:** `arr[i]` becomes `T | undefined`, forcing narrowing. Costs little, prevents a class of out-of-bounds deref bugs. +- **Applies `devops/P1` (reproducibility):** pinned `typescript` version in `package.json` and `lockfile` ensure every CI run type-checks against the same compiler. + +```jsonc +// tsconfig.json — base +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "moduleResolution": "bundler", + "isolatedModules": true + } +} +``` + +## ESLint and @typescript-eslint (DevOps P2 Automation, Documentation P9 Living Documents) + +- **ESLint with `@typescript-eslint` strict ruleset:** `recommended-type-checked` enables rules that require the type checker (`no-floating-promises`, `no-misused-promises`). +- **Rules encode decisions, not taste:** every custom rule in the config has a one-line `// reason:` comment linking to the principle it enforces. This makes the config a living document (Documentation P9). +- **Format is Prettier's job; ESLint lints:** `eslint-config-prettier` disables conflicting format rules. Do not relitigate formatting in code review. +- **`no-floating-promises` enforces `concurrency/P8` (timeout discipline):** an un-awaited `Promise` is a fire-and-forget that swallows errors and timeouts. The rule forces `.catch()` or `await`. + +```jsonc +// .eslintrc.json +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended-type-checked", + "prettier" + ], + "parserOptions": { "project": "./tsconfig.json" }, + "rules": { + // reason: enforce Concurrency P8 — no un-awaited promises + "@typescript-eslint/no-floating-promises": "error", + // reason: enforce Data P7 — no `any` escaping the type checker + "@typescript-eslint/no-explicit-any": "error" + } +} +``` + +## Project References and ts-jest (DevOps P2 Automation, C6 Composability) + +- **Project references for monorepos:** `composite: true` + `references` let `tsc --build` incrementally type-check only changed projects, and enforce the dependency graph at the type level. +- **`paths` aliases mirror the import structure:** `@app/*` → `src/*`. Configure once in `tsconfig`, mirror in the bundler and the test runner so all three agree. +- **`ts-jest` (or `vitest`) with `isolatedModules: true`:** each test file is type-checked in isolation, matching how the bundler transpiles. Catches the "passes in `tsc` but fails in the bundler" gap. +- **Applies `devops/P2`:** the build pipeline (tsc → lint → test → bundle) is automated; a developer never runs a manual sequence. + +```jsonc +// tsconfig.references.json +{ + "files": [], + "references": [ + { "path": "./packages/core" }, + { "path": "./packages/api" }, + { "path": "./packages/web" } + ] +} +``` + +## Lockfile and Reproducible Install (DevOps P1 Reproducibility) + +- **`npm ci` in CI, not `npm install`:** `ci` reads the lockfile exactly and fails on drift. `install` mutates the lockfile. +- **Lockfile committed for applications:** for libraries, commit `package-lock.json` for CI reproducibility even though consumers resolve their own tree. +- **No floating ranges in `package.json`:** `^` and `~` are CI's job to resolve; pin the resolved version in the lockfile. An unpinned `*` is a supply-chain attack surface. + +```bash +# CI install step — deterministic +npm ci +# Type-check gate +npx tsc --noEmit +# Lint gate +npx eslint . +``` + +## Documentation in the Pipeline (Documentation P1 Documentation is Code, DevOps P9 Documentation in the Pipeline) + +- **Type-checked JSDoc:** `typedoc` (or `TypeDoc`) generates API docs from `tsdoc` comments. The compiler enforces that `@param` names match real parameters. +- **`@example` blocks are compiled:** a `tsdoc` `@example` fenced block is type-checked as part of the doc build. Stale examples fail the pipeline (Documentation P1 — docs are code). +- **README badges reflect CI status:** the build/lint/test/type-check gates are the source of truth; badges surface them. Do not hand-edit status tables. + +```typescript +/** + * Fetch a user by ID. + * + * @param id - a branded UserId (see ts-types.md). + * @throws {NotFoundError} if the user does not exist. + * @example + * ```ts + * const u = await getUser(userId('abc')); + * ``` + */ +async function getUser(id: UserId): Promise { /* ... */ } +``` + +## Cross-References + +- `domains/devops/ci-cd.md` — the pipeline gates that host tsc/ESLint/ts-jest. +- `domains/devops/first-principles.md` — DevOps P1 Reproducibility, P2 Automation. +- `domains/documentation/first-principles.md` — Documentation P1 Documentation is Code. +- `languages/ts-types.md` — the type rules ESLint enforces reference this doc. +- `languages/ts-testing.md` — the test-runner config (`ts-jest`/`vitest`) detailed here. \ No newline at end of file diff --git a/languages/ts-types.md b/languages/ts-types.md new file mode 100644 index 0000000..ef83a0c --- /dev/null +++ b/languages/ts-types.md @@ -0,0 +1,111 @@ +# TypeScript Type System — Derived Application + +> Applies Atelier's domain principles to TypeScript's type system specifically. +> Derives from `domains/` docs; introduces no new P-rules (D-063). +> See `languages/typescript.md` for the language first-principles stub. + +## Nominal vs Structural Typing (C1 Correctness, Data P7 Type Fidelity, API P1 Contract Fidelity) + +- **TypeScript is structurally typed:** two types with the same shape are assignable. This is convenient but erases domain boundaries — a `UserId` and `PostId` both `string` are interchangeable. +- **Branded (nominal) types for domain IDs:** intersect with a phantom brand to simulate nominal typing. The brand is never constructed at runtime; it exists only to the type checker. +- **Applies `data/P7` (type fidelity)** at the value boundary: a branded `UserId` cannot be passed where a `PostId` is expected, preventing an entire class of swap bugs. +- **Applies `api/P1` (contract fidelity):** branded types make API contracts explicit — handlers cannot accept "any string" for an ID. +- **Brand is opaque to consumers:** do not export the brand symbol; construction goes through a validated factory. + +```typescript +type UserId = string & { readonly __brand: 'UserId' }; +type PostId = string & { readonly __brand: 'PostId' }; + +function userId(s: string): UserId { + if (!/^[a-zA-Z0-9]+$/.test(s)) throw new Error('invalid id'); + return s as UserId; +} + +function getUser(id: UserId): User { /* ... */ } +getUser('abc'); // type error +getUser(userId('abc')); // ok +getUser(postId('xyz')); // type error — distinct brands +``` + +## Generics (C6 Composability, Data P7 Type Fidelity) + +- **Generics preserve type information across boundaries:** a `Repository` keeps the element type through `find`/`save` rather than widening to `any`. +- **Constrain with `extends`:** `` documents the contract and gives the body access to `T.id`. +- **Avoid unnecessary generics:** if a function accepts "any value and returns it unchanged," `T` is noise. Prefer `unknown` for truly opaque inputs. +- **Variance is structural:** TS does not enforce sound variance; mark mutation points with `readonly` to keep `T[]` assignable to `readonly T[]`. + +```typescript +interface Entity { id: string } +class Repository { + constructor(private db: Map) {} + find(id: string): T | undefined { return this.db.get(id); } + save(t: T): void { this.db.set(t.id, t); } +} +``` + +## Narrowing and Type Guards (C1 Correctness, Errors P1 Errors are Data) + +- **Narrowing is how TS handles `unknown` and union types safely:** `typeof`, `in`, `instanceof`, and discriminators collapse a wide type to a precise one before use. +- **User-defined type guards (`x is T`) encode domain predicates:** `isUser(x): x is User` lets the checker track the narrow across call sites. +- **Applies `errors/P1` (errors are data):** a `Result` discriminated union is narrowed with `if (r.ok)` — no `try`/`catch` needed for expected failures. +- **Never use `as` to widen past a check:** `as` lies to the compiler. If narrowing does not reach the type you need, the predicate is wrong, not the cast. + +```typescript +type Result = { ok: true; value: T } | { ok: false; error: E }; + +function unwrap(r: Result): T { + if (r.ok) return r.value; // narrowed to { ok: true; value: T } + throw new Error(JSON.stringify(r.error)); +} + +function isUser(x: unknown): x is User { + return typeof x === 'object' && x !== null && 'id' in x && 'name' in x; +} +``` + +## Utility Types (C5 Reversibility, C6 Composability) + +- **`Partial`, `Pick`, `Omit`, `Readonly` are derived views:** they derive from a source-of-truth `T` rather than redeclaring fields, so the source change propagates (reversibility). +- **`Readonly` enforces immutability at the type level** — applies `concurrency/P1` (immutability by default) without runtime cost. +- **`Record` over `{ [k: string]: V }`:** the index signature form allows any string key including prototype pollution vectors; `Record` is exact. +- **Compose, don't accumulate:** `type Patch = Partial>` reads as a transformation; restate it if `T` changes shape, rather than maintaining a parallel `Patch` type. + +```typescript +interface User { id: string; name: string; email: string; } +type UserPatch = Partial>; +type ReadonlyUser = Readonly; +type UsersById = Record; +``` + +## Discriminated Unions (C1 Correctness, Data P7 Type Fidelity, Errors P1 Errors are Data) + +- **Discriminated unions over enums:** `type Status = { type: 'pending' } | { type: 'paid'; amount: number }` is exhaustive and carries payload per variant; an `enum` carries neither. +- **The discriminant is a literal `type` (or `kind`) field:** the checker narrows on it in `switch` and `if` without a custom guard. +- **Exhaustiveness via `never`:** assign the narrowed value to `never` in the default branch; if a variant is added, the default fails to compile. +- **Applies `errors/P1`:** model domain errors as a discriminated union `AppError = NotFound | Validation | Conflict`, not as exception classes — the type system carries the error set. + +```typescript +type Status = + | { type: 'pending' } + | { type: 'paid'; amount: number } + | { type: 'refunded'; reason: string }; + +function describe(s: Status): string { + switch (s.type) { + case 'pending': return 'awaiting payment'; + case 'paid': return `paid ${s.amount}`; + case 'refunded': return `refunded: ${s.reason}`; + default: + const _exhaustive: never = s; // compile error if a variant is added + throw new Error('unhandled'); + } +} +``` + +## Cross-References + +- `domains/data/schema-design.md` — schema-level fidelity parallels branded types at the TS boundary. +- `domains/data/first-principles.md` — Data P7 Type Fidelity, the primary trace for this doc. +- `domains/api/rest.md` — contract fidelity for API handlers consuming branded IDs. +- `domains/errors/patterns.md` — discriminated unions as the error-as-data encoding. +- `languages/ts-async.md` — typed async results built on the `Result` union here. \ No newline at end of file diff --git a/languages/typescript.md b/languages/typescript.md index c144fdf..6313688 100644 --- a/languages/typescript.md +++ b/languages/typescript.md @@ -2,6 +2,13 @@ > How Atelier's domain principles apply in TypeScript specifically. Derives from `domains/` docs; this file is the language-specific lens. +## Derived Docs + +- [ts-types.md](ts-types.md) — TS type system: nominal-via-branding, generics, narrowing, utility types, discriminated unions. +- [ts-tooling.md](ts-tooling.md) — tsc, ESLint, ts-jest, project references, tsconfig discipline. +- [ts-async.md](ts-async.md) — Promises + AbortSignal, async/await, error handling, cancellation. +- [ts-testing.md](ts-testing.md) — Vitest/Jest, mock discipline, type-level tests. + ## Type System (C1 Correctness, Data P7 Type Fidelity) - **Strict mode on:** `strict: true` in `tsconfig.json`. No `any` without justification.