Files
awesome-copilot/skills/poka-yoke/references/lang-rust-go.md
T
Shannon Atkinson 7c36d87f82 Add poka-yoke skill: make misuse unrepresentable rather than warned against (#2792)
* Add poka-yoke skill: make misuse unrepresentable rather than warned against

Mistake-proofing, applied to code. The skill pushes toward devices that make
a wrong action impossible or self-announcing, rather than instructions asking
someone to avoid it, and refuses to accept a comment or a convention as a fix.

The gap it addresses. Given a design, models readily list what to fix and
rarely state what the fix makes impossible. Measured across 591 blind-graded
runs and six model families, responses named the foreclosed set 42% of the
time unprompted and 81% with this skill applied. Assertions were written
before the runs and graded blind to configuration.

Stated with its limits, because they matter: every run was the first turn of
a fresh session, so this measures the ceiling rather than what survives a
long working session; the comparison was against no methodology rather than a
different one, so it does not establish that this particular method caused
the gain; and the skill costs something measurable, making responses somewhat
worse at spotting the specific defect already on the page while better at
changing the shape that allowed it. All of that is in the skill body rather
than omitted.

Bundled, all self-contained, no network access and no dependencies:
  scripts/detect_hazards.py     standard-library scanner, 42 pattern rules
                                across 20 hazard shapes, five languages
  references/hazard-catalog.md  the taxonomy with device per shape
  references/lang-*.md          Python, TypeScript, Go and Rust patterns

Verified with npm run skill:validate and npm run build. Raw benchmark runs,
the harness and the assertion checklists are public at
https://github.com/rainmanjam/poka-yoke

* Rename HasTable to HasFrom in the TypeScript typestate example

codespell failed the PR: it reads HasTable as a misspelling of hashtable.
The identifier is a legitimate generic parameter on a typestate builder,
QueryBuilder<HasTable, HasWhere>, so this is a false positive, but it is our
file breaking their gate and the fix costs nothing.

HasFrom is also the better name: the flag tracks whether .from() has been
called, not whether a table exists.

Renamed in the upstream repository too, so the two copies do not diverge on
day one. Verified the whole bundle is codespell-clean locally.

* Correct the effect figures in the poka-yoke skill

The submitted numbers (42% -> 81%) could not be reproduced from the upstream
gradings. Recomputed over the six models the sentence describes: 45% -> 80%
across 132 verdicts. Adds the per-scenario breakdown, which is the more useful
claim: the gains are in tasks where nobody asked for a design review.
2026-08-26 09:56:32 +10:00

214 lines
7.6 KiB
Markdown

# Rust and Go Devices
Two languages at opposite ends of the expressiveness spectrum. Rust can encode almost any
invariant in types; Go deliberately cannot, so its devices lean on convention plus tooling.
Know which one you're in before proposing a device.
---
# Rust
Rust's type system reaches Control for more hazard classes than any other mainstream language.
The affine type system in particular is the only mainstream answer to use-after-move, and it
turns use-after-close into a compile error rather than a convention, where Python has context
managers, TypeScript has scope-bound callbacks, and Go has `defer`, Rust has the compiler.
## Contact, newtypes and smart constructors
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
#[derive(Debug, Clone)]
pub struct Email(String);
impl Email {
// The only way to build one. Private field means no bypass, even in-crate
// if you put it behind a module boundary.
pub fn parse(s: &str) -> Result<Self, InvalidEmail> {
if !s.contains('@') { return Err(InvalidEmail); }
Ok(Email(s.to_owned()))
}
pub fn as_str(&self) -> &str { &self.0 }
}
```
A private field plus a fallible constructor means possessing an `Email` *is* proof of
validation. This is the strongest form of parse-don't-validate available anywhere.
## Contact, enums make illegal states unrepresentable
```rust
// Each variant carries exactly the data that variant has. There is no
// "succeeded with an error", because it cannot be written.
pub enum JobState {
Queued { enqueued_at: DateTime<Utc> },
Running { started_at: DateTime<Utc>, worker: WorkerId },
Succeeded { output: Output },
Failed { error: JobError, retries: u32 },
}
```
`match` without a catch-all is exhaustive by default, adding a variant breaks the build
everywhere it must. Avoid `_ => {}` arms in domain logic for exactly this reason: the wildcard
is what turns a compile error into a silent fallthrough two releases later.
## Motion-step, typestate
Ownership makes typestate genuinely practical, since each transition consumes the old state:
```rust
pub struct Draft;
pub struct Validated;
pub struct Order<S> { items: Vec<Item>, _state: PhantomData<S> }
impl Order<Draft> {
pub fn validate(self) -> Result<Order<Validated>, ValidationError> { /* … */ }
}
impl Order<Validated> {
// submit() does not exist on Order<Draft>. Not "returns an error", does not exist.
pub fn submit(self) -> Result<OrderId, SubmitError> { /* … */ }
}
```
The consumed `self` means the draft is gone after validation, so a stale unvalidated copy
cannot be submitted later.
## Fixed-value, make errors impossible to ignore
`#[must_use]` on `Result` is built in; add it to your own types where dropping the value is a
bug. Then set the lints:
```toml
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "warn" # allow in tests and startup with a reason
panic = "deny"
indexing_slicing = "deny" # forces .get() and a real branch
float_cmp = "deny"
arithmetic_side_effects = "warn" # forces checked_/saturating_ where overflow matters
todo = "deny"
dbg_macro = "deny"
```
`unwrap_used = "deny"` is the highest-value line in that block: it converts every "this can't
fail" assumption into an explicit decision at review time.
## Rust limits
- **`unsafe` and `unwrap` are the escape hatches.** Deny both by lint and require a
`// SAFETY:` comment for each `unsafe` block.
- **Compile-time only.** Deserialized input still needs `serde` with `deny_unknown_fields`.
- **Panics bypass the type system.** A device that panics is Warning, not Control.
- **Typestate has real ergonomic cost.** Reserve it for genuinely dangerous sequences, payments, resource lifecycles, protocol state: not for every builder.
---
# Go
Go rejects most compile-time expressiveness by design. Its devices are therefore fewer, and
tooling plus data-layer constraints carry more of the load. Say so plainly when you propose a
device, Control is often not reachable here, and pretending otherwise is worse than
acknowledging the rung.
## Contact, defined types
```go
type UserID string
type OrderID string
func Transfer(from, to UserID) error { ... }
// Transfer(orderID, userID), compile error, because these are defined types, not aliases.
```
Use `type X string` (a defined type), never `type X = string` (an alias, which gives you
nothing). This is the one genuine Control-rung contact device Go offers, and it is
underused.
## Contact, functional options instead of boolean flags
```go
type Option func(*Config)
func WithTimeout(d time.Duration) Option { return func(c *Config) { c.Timeout = d } }
func WithRetries(n int) Option { return func(c *Config) { c.Retries = n } }
func New(addr string, opts ...Option) (*Client, error) { ... }
```
Every option is named at the call site, `time.Duration` carries its unit in the type, and
adding an option later doesn't break callers. This replaces both the boolean-flag hazard and
the implicit-units hazard.
## Motion-step, constructors and defer
```go
func NewClient(addr string) (*Client, error) {
// Fully ready on return. No Connect() to forget.
}
conn, err := pool.Acquire(ctx)
if err != nil { return err }
defer conn.Release() // on the line after acquisition, always
```
Put `defer` immediately after the acquisition, before any other statement. Any code between
the two is a leak on the error path.
## Fixed-value, exhaustiveness
Go has no exhaustive switch. Use a linter:
```yaml
# .golangci.yml
version: "2"
linters:
enable:
- errcheck # unchecked errors: the single most valuable Go linter
- exhaustive # non-exhaustive switch over typed constants
- bodyclose # unclosed HTTP response bodies
- rowserrcheck # unchecked sql.Rows.Err
- sqlclosecheck
- contextcheck # context not propagated
- nilerr # returning nil after a non-nil error
- noctx # HTTP requests without a context
- gosec
settings:
exhaustive:
default-signifies-exhaustive: false
```
That is the v2 schema. golangci-lint v2 refuses to run against a v1 file rather than ignoring
the parts it no longer understands, so run `golangci-lint migrate` over an existing config
before upgrading.
`errcheck` is non-negotiable, Go's error convention is entirely opt-in without it, and
`_ = doSomething()` is how data loss enters a Go codebase.
## Go-specific traps
- **Nil maps** accept reads but panic on write. Construct with `make` in the constructor.
- **Loop variable capture** in goroutines, fixed in Go 1.22+, still present in older
codebases and vendored code.
- **`time.Duration` vs bare int**: always take a `Duration`; never an `int` seconds.
- **Zero values are valid**, so a struct with a missing field looks initialized. Use a
constructor that returns `(T, error)` and unexported fields to force it.
- **Slices share backing arrays**, `append` to a sub-slice can mutate the original. Use
three-index slicing `s[a:b:b]` when handing a slice out.
- **`context.Context` dropped** across a call boundary silently disables cancellation and
timeouts. `contextcheck` catches it.
## Go limits
Go cannot express: exhaustive matching, non-nullable references, immutability, typestate, or
generic constraints rich enough for units. Its Control-rung devices are essentially defined
types, unexported fields with constructors, and the database schema.
The practical consequence: in Go, **push more invariants into the database and into required
CI checks** than you would in Rust or TypeScript. `NOT NULL`, `CHECK`, and unique constraints
are doing work the language declines to do, and `golangci-lint` as a required check is what
makes the rest hold.