Search/Have AI Write Systems Code Carefully, Down to Types and Error Handling

Have AI Write Systems Code Carefully, Down to Types and Error Handling

Use when an AI helps on a strict systems codebase. It reviews its code at least three times as long as writing it, covers every error case, puts rules into types, keeps old and new file formats compatible, and makes one-change commits that each build.

To use this AI rule
## Review AI-Written Code at Least Three Times as Long as It Took to Write Treat the assistant as a quality multiplier, not a speed multiplier. Spend the time it saves on rigor beyond what unaided work would reach. - Aim for excellence: cover more edge cases, refactor for understandability, clear the outstanding TODOs, do the tedious things, and target zero bugs. - Review far longer than you generate. Spend at least three times as long reviewing generated code as it took to produce, weighing every line and every design decision, and actively looking for ways to break it. - The author owns the result. Do not hand a first draft over as finished work unless you are explicitly only asking for feedback on a direction. - Work that shows signs of being produced without care, such as missing cases an attentive human review would have caught, may be declined outright rather than revised. - Declare the standard at the start of any conversation involving code changes, and again before opening a pull request, so author and reviewer share the same bar. ## Push Correctness into Types and Handle Every Edge Case Prefer the correct implementation to the convenient one, and push as much correctness as possible into the type system so mistakes fail to compile rather than fail in production. - Model the full error space. No shortcuts and no simplified error handling. - Handle all edge cases, including race conditions, signal timing, and behavioral differences between operating systems. - Encode correctness constraints in types: wrapper types for domain values, builder patterns for complex construction, type states in generics when state transitions matter, and borrowing instead of cloning. - Prefer compile-time guarantees to runtime checks wherever the choice exists. - Keep visibility as narrow as possible. Widen an item only when something outside its module genuinely needs it. - Mark public types in stable, externally consumed packages as extensible for forward compatibility. Internal packages that make no stability promise do not need this, though error types may still want it. - Use message passing or the actor model instead of shared mutable state, so data races cannot arise. - Getting the details right matters more than finishing quickly. ## Give Users Helpful Error Messages and the Same Behavior on Every OS User-facing behavior is a design input, not a finishing touch. - Produce structured, helpful diagnostics rather than bare error strings, and use a rich diagnostic library wherever the message reaches a person. - Make progress reporting responsive and informative. - Keep behavior consistent across platforms even where the underlying capabilities differ, and implement each platform with its own native mechanisms instead of emulating one platform on another. - Write user-facing messages in clear present tense: "the tool now supports", not "the tool now supported". - Centralize pluralization in a shared helper module whose functions return the singular or plural form for a count. Add new helpers there rather than inlining pluralization logic at call sites. ## Grow the Design Step by Step and Write Down Why Keep the design specific and composable rather than abstract and overly generic. - Evolve the design incrementally instead of attempting a perfect architecture up front. - Record design decisions and their trade-offs in design documents kept with the project, so later readers can see why a shape was chosen. - When uncertain, explore and iterate rather than freezing a guess into an abstraction. ## Separate User Errors from Internal Bugs and Attach Detailed Context Errors are part of the surface a user sees and deserve the same design attention as the successful path. - Use derive-based error types instead of stringly-typed errors, and group related errors under a kind enumeration once the set grows. - Carry rich, structured context on errors rather than flattening the detail into a single message. - Use a two-tier model. User-caused and external errors form a distinct type that maps to semantic exit codes; internal programming errors are separate and may panic. - Write error display text as a lowercase sentence fragment that reads correctly when embedded in a wrapper phrase such as "failed to ...". ## Warn on Unknown Config Keys and Keep Config Errors Clear Deserialization decisions determine the quality of every configuration error a user will ever see. - Track and report ignored or unknown paths in configuration, so a typo surfaces as a warning instead of silently doing nothing. - Never flatten nested structures during deserialization. The internal buffering it needs degrades unknown-field warnings; copy the fields into the struct explicitly instead. - Never rely on untagged variant deserialization, which produces poor error messages. Write a custom visitor with a precise expectation string instead. ## Check That Old and New Versions Can Read Each Other's Data Before Changing a Saved Format Any change to a structure written to disk or sent over the wire must be traced through the full compatibility matrix before it lands. - Old reader with new data: can it still deserialize, and does it lose information? - New reader with old data: do the declared defaults produce correct values, not merely valid ones? - Old writer with new data: can it round-trip without dropping fields? This is the case most often missed. - Bump the format version proactively. If a new field will be semantically important, raise the version when the field is added, not when a non-default value is first written; otherwise older builds silently corrupt data when they write it back. - Treat default values as necessary but not sufficient. They let an old reader parse new data, but an old writer still drops the fields it does not know about. ## Use Async Only Where Needed and Don't Spawn Work Without Limit Asynchrony is a tool for input and output and for concurrency, not a default style. - Use a multi-threaded async runtime, but confine async to the code that needs it and keep everything else synchronous. - Use structured concurrency so concurrent work does not have to be given an artificially long lifetime. - Schedule queued work with backpressure awareness instead of spawning without limit. - Provide pausable timing primitives where job control can suspend and resume the process, so suspended time is not charged against timeouts. ## Keep Logic Out of Aggregator Modules and Put Imports at the Top Module boundaries are enforced by structure and visibility, not by convention alone. - Use aggregator module files only to re-export public items. Nontrivial logic belongs in an implementation submodule or a more specific one. - Keep platform-specific code in separate per-platform files behind conditional compilation, with a shared interface between them. - Keep test helpers in dedicated modules or files. - Import types and functions at the very top of the module. Never import inside a function body; the one exception is a conditionally compiled function that needs a gated import. - Prefer importing the type itself, and import a module instead only where that is the conventional spelling. Avoid fully qualified paths in signatures: import the parent module and qualify with just that. - Importing enumeration variants for pattern matching is fine. ## Borrow or Share Data Instead of Copying It Choose the representation that avoids work rather than optimizing after the fact. - Share immutable data by reference counting or borrowing instead of copying it. - Use a type optimized for small strings where the code handles many short strings. - Pay attention to cloning and referencing. Avoid cloning where the data has a natural tree or ownership structure that borrowing already expresses. - Stream data through iterators where possible instead of buffering it all in memory. ## Run the Formatter Before Every Commit and Manage Dependency Versions in One Place Mechanical consistency is cheap to enforce and expensive to retrofit. - Start every source file with the project's copyright line and license identifier. - Pin the language edition, and run the project's formatting command before every commit. Formatting is enforced in continuous integration, so an unformatted commit fails there. - Suppress a lint with the expecting form rather than the allowing form, so the suppression itself warns once the lint no longer fires and dead suppressions do not accumulate. - Where a lint forbids appending formatted text to a string, use the project's writing macro instead of pushing a formatted string. - Manage all dependency versions centrally at the workspace root, and pin internal packages to exact versions. - Comment on non-obvious dependency and feature choices, especially where a feature is disabled for a security or size reason. ## Write Comments That Explain Why, Not Just What - Comments explain why, not merely what. - Do not narrate inside function bodies. Add a comment only where something is non-obvious or special, or where the intent needs a deeper explanation. - Module-level documentation states the module's purpose and responsibilities. - Always end code comments with a period. - Never use title case in headings and titles; always use sentence case. - Always use the Oxford comma. - Do not omit articles. Write "the file has a newer version", not "file has newer version". ## Run Tests with the Project's Own Test Runner and Reuse Existing Fixtures Test the way the project runs tests, and reuse the testing model that already exists. - Use the project's designated test runner for unit and integration tests. Do not fall back to the language's default runner when the suite depends on the designated runner's execution model. - Use a locally built copy of the tool only when verifying changes to the tool's own behavior, such as a new flag or output format. Run the suite itself with the standard runner, since integration tests spawn their own inner processes from the build artifacts. - Run documentation tests with the command that supports them when the main runner does not. - Keep unit tests beside the code they test, integration tests in a dedicated package, shared fixtures in a fixture package, and test utilities in a support package. - Prefer the fixture package's model of expected results over hand-written spot checks. - Reach for the tooling that already exists: parameterized cases, property-based testing, snapshot assertions, custom harnesses, and improved assertion output. Check what is available before adding a new mechanism. ## Start Commit Subjects with a Scope and Keep Every Commit Buildable History is a reviewable artifact, so treat each commit as a unit of review. - Prefix each commit subject with the scope it touches in brackets, followed by a brief description. Use a meta scope for cross-cutting work such as toolchain minimums, releases, and continuous integration changes. - Version bump and release-preparation commits follow their own fixed subjects and are normally produced by the release tool. - Write bodies in simple past and present tense: what used to happen, what happens now, and which tests were added. - Commit messages are Markdown. Do not use backticks in the subject line, but do use them in the body. - Make commits atomic. Each commit is one logical unit of change. - Keep history bisectable. Every commit must build and pass all checks. - Separate concerns. Formatting fixes and refactors belong in their own commits, apart from behavior changes. ## Split a Concurrent Runner into a Dispatcher and an Executor That Talk by Messages A concurrent runner is easiest to reason about when one component owns the outside world and another owns execution. - A dispatcher interacts with the outside world, linearizes events from every source, multiplexes over those sources with a select construct, and holds the authoritative state of what is currently running. Synchronization points keep reporting free of races. - An executor schedules and runs units of work as async state machines, handling retries, timeouts, process spawning, and output capture. Each unit is the source of truth for its own state and has a dedicated channel to the dispatcher rather than a broadcast. - Share no state directly; everything crosses between components as messages. - Model the full failure space, and use pausable timers so suspend and resume signals do not distort timing. - Use each platform's native process containment and signal facilities, guard them with conditional compilation and per-platform modules behind a shared interface, and document the platform differences and trade-offs in comments.