Checks a package or path: boundaries, state, dependencies, and the public surface. Use it for this input: package / path. Report each check as PASS, FAIL, or N/A with file:line evidence. A FAIL carries the card's severity. Any blockFAIL means request changes. A should-fix or discussFAIL goes in the review but does not block.
FAIL [block] chg-expand-contract - src/a.py:42 - renames the column and drops the old one in one migration
FAIL [should-fix] chg-one-concern - src/b.py:7 - a logging rewrite rides along with the bug fix
PASS chg-breaking-change-taxonomy - src/c.py:15 - no signature or return shape changes
N/A chg-perf-claim-evidence - the description makes no performance claim
Checklist
CardSeverityConcernsRule
mod-colour-tokens-meet-contrastshould-fixaccessibilityusabilityMake every text and background token pair meet 4.5:1 (3:1 for large text), and every border, icon, and focus colour meet 3:1 against its neighbour.
mod-derive-dont-duplicateshould-fixcompatibilitymaintainabilityDerive a type, list or enum from the schema or list that owns it, or add a build check that fails when the two disagree.
mod-single-writer-per-stateshould-fixcorrectnessLet only one writer change a piece of state at a time, through a single owner, a queue that serialises the writes, or compare-and-swap, never by convention.
mod-vetted-crypto-onlyblocksecurityEncrypt with an authenticated mode such as AES-GCM, and never reuse a nonce under one key.
mod-yagnishould-fixmaintainabilityprocessAdd an abstraction only when the logic is reused, non-trivial, or clutters the main flow; do not build for a need that does not exist yet.
What the badges mean
blockmust be fixed before merge or release.
should-fixfix it, but it need not block.
discussraise it and decide together.
stablevetted; use for verdicts.
draftnot vetted; do not use for a verdict.
Tool gate: what a linter or CI check could enforce instead of a reviewer.
Filters
Concerns
Topic tags
Topics
Cards
mod-colour-tokens-meet-contrast
should-fixstableaccessibilityusability
Applies when: A design-system module or theme defines foreground and background colour tokens.
Rule. Make every text and background token pair meet 4.5:1 (3:1 for large text), and every border, icon, and focus colour meet 3:1 against its neighbour.
Why. Low contrast is invisible to the author on a bright monitor. Fixing it once in the tokens fixes every component. Fixing it per component means hunting through each one, and each new component can reintroduce it.
FAIL
:root {
--text-muted: #9aa0a6; /* 2.6:1 on white */
--bg: #ffffff;
--input-border: #e0e0e0; /* 1.3:1 on white */
}
PASS
:root {
--text-muted: #5f6368; /* 6.0:1 on white */
--bg: #ffffff;
--input-border: #767676; /* 4.5:1 on white, above the 3:1 floor */
}
Counterpoint. "The brand colour fails." A logo is exempt. A brand-coloured button label or link is not. Darken the shade used for text and keep the brand shade for fills. The ratio is exact: 4.499:1 fails.
Tool gate: axe color-contrast on rendered components; a unit test that computes the ratio for each token pair. axe cannot see text on images or gradients.
status.py is generated from openapi.yaml in the build, or a test asserts the two sets are equal
Counterpoint. "Generated code is awkward to read." A parity test gives the same safety with hand-written types. The rule fails only when neither derivation nor a check exists.
Tool gate: Codegen from the schema, or a test that diffs the two lists.
Applies when: Code creates a network or database client.
Rule. Build pooled clients once at startup and reuse them.
Why. A client per call defeats connection pooling, leaks sockets, and pays a handshake every time. When a client must be short-lived, close it when the work ends.
FAIL
async Task<string> Fetch(string url) {
using var client = new HttpClient(); // new pool and socket per call
return await client.GetStringAsync(url);
}
Applies when: More than one module, worker or process writes the same state.
Rule. Let only one writer change a piece of state at a time, through a single owner, a queue that serialises the writes, or compare-and-swap, never by convention.
Why. A convention such as "only write your own field" is not concurrency control. Two workers writing different fields of one file still overwrite each other, and two deploy runs that overlap can undo each other's changes. Better still, split the state so that each writer owns its part and readers merge the parts: writers to different parts then never wait for each other.
FAIL
worker A and worker B each load state.json, set their own key, and save the whole file
PASS
workers send updates to one queue; a single consumer applies them to state.json in order
or: state/a.json and state/b.json, one writer each; readers merge them
Counterpoint. "A lock is simpler than a queue or an owner." A lock works only when the store rejects a writer whose lock has expired; see sys-lock-fencing. Without that check, a paused holder can wake up and write after another writer took over.
Rule. Encrypt with an authenticated mode such as AES-GCM, and never reuse a nonce under one key.
Why. Unauthenticated modes allow silent tampering, and ECB also leaks the structure of the plaintext. Reusing a nonce under one key breaks GCM completely: it leaks the XOR of the plaintexts and the authentication key. Take the call from a maintained library and never write your own cipher. Keys belong in a KMS or secrets manager, never in source; sys-secrets-and-errors covers where secrets live at runtime.
FAIL
cipher = AES.new(KEY, AES.MODE_ECB)
ct = bytes(b ^ k for b, k in zip(msg, KEY * 99))
Counterpoint. "The library API is awkward, so a wrapper is safer." Wrap the vetted call, do not replace it. Review the wrapper only for nonce handling and key sourcing.
Tool gate: A grep for MODE_ECB, MODE_CBC with no MAC, and nonces built from constants.
Applies when: The change adds an abstraction, parameter, hook or option.
Rule. Add an abstraction only when the logic is reused, non-trivial, or clutters the main flow; do not build for a need that does not exist yet.
Why. Speculative flexibility costs reading time every day and is usually the wrong shape when the need arrives. Reviewers cannot verify a use that does not exist.
FAIL
class NotifierFactory: ... # one product, one caller
def send(msg, retries=3, backoff=None, hook=None): ... # nobody passes these
PASS
def send_welcome_email(user): ...
Counterpoint. "But we will surely need it." Then add it when you do, in a change that has the real second caller to shape it. Optimising for easy change means simple code, not built-in flexibility. Trivial single-use logic stays inline. The cost of waiting is low, and the cost of the wrong guess is not.
A draft card is not vetted. Read it for ideas, but do not use it for a verdict.
mod-behaviour-lives-with-state
discussdraftmaintainability
Applies when: A service function computes something only from one object's own fields.
Rule. Put invariants and calculations over one object on that object, and move logic to a service only when it spans objects or needs an injected collaborator.
Why. An anemic model scatters the rules about an object across services, and every caller needs to know where a rule lives.
FAIL
def order_total(order): return sum(l.price * l.qty for l in order.lines)
PASS
class Order:
@property
def total(self): return sum(l.price * l.qty for l in self.lines)
Counterpoint. "Data classes should stay dumb." That is a style choice, not a free pass. If the calculation needs I/O or other aggregates, a service is right. The rule targets only functions that read nothing but one object.
mod-central-config
should-fixdraftmaintainability
Applies when: Business code reads environment variables or holds configuration values.
Rule. Read configuration in one settings module and pass it in; business code must not read the environment or hold knobs.
Why. Scattered reads hide what the app depends on and make tests mutate the process environment.
FAIL
timeout = float(os.environ.get("TIMEOUT", "5")) # inside a service
PASS
settings.request_timeout_s # defined once in settings.py, injected
Counterpoint. "One more env read is harmless." Each one hides a deployment dependency from review and from the docs. One module is also where you add validation.
Applies when: A module or package is large, mixes concepts, or holds a very large literal.
Rule. Keep one cohesive concept per module.
Why. Banner comments and # ---- section ---- markers show a module doing several jobs. Large literals bury the logic around them. Split an oversized package into subpackages, move big literals to their own module, and inline a folder that holds one module.
FAIL
service.py (1800 lines): prompts, parsing, billing, formatting; with # ==== banners
pkg/only_one.py # a folder for a single module
PASS
service.py (logic), prompts.py (the big literal), billing.py ...
only_one.py # folder inlined
Counterpoint. "Splitting scatters related code." True when the split is by layer or time rather than by concept. Ousterhout warns about temporal decomposition. Split by owned knowledge, not by when things run.
Applies when: A new class or module is introduced, or an existing interface grows.
Rule. Prefer a small interface that hides real work, and flag modules whose interface is nearly as complex as their implementation.
Why. A shallow module charges the reader the cost of learning an interface and returns nothing. The complexity moves; it does not shrink.
FAIL
class Reader:
def open(self); def read_line(self); def set_encoding(self, e); def close(self)
# caller must sequence all four; same work in a function would be one call
Counterpoint. Ousterhout and Martin disagree. Martin favours many very small functions, and Ousterhout says that yields shallow, entangled pieces. Evidence is thin on both sides. Ask the local question: does the split help a reader here?
Applies when: A reviewer sees long methods, feature envy, data clumps, primitive obsession, speculative generality, or divergent change.
Rule. Name the smell, point to the line, and suggest the refactoring; do not file smells as taste.
Why. Smells are cheap to see and expensive to ignore, but a vague "this feels off" gives the author nothing to fix.
FAIL
review: "This is messy."
PASS
review: "should-fix: price_total reads only Order fields (feature envy). Move it to Order."
Counterpoint. "Smells are subjective." Yes: they signal; they do not prove. That is why the comment names the smell, the line, and the refactoring. "This is messy" gives the author nothing to act on.
Applies when: A class uses a database, HTTP, cloud or clock collaborator.
Rule. Inject boundary collaborators through the constructor; do not construct them inside or reach for a global.
Why. Hard-wired collaborators force tests to patch module internals and tie the class to one environment.
FAIL
class Billing:
def charge(self, o): return StripeClient(KEY).charge(o)
PASS
class Billing:
def __init__(self, gateway): self._gateway = gateway
Counterpoint. "Injection everywhere is enterprise bloat." Inject only the boundary (DB, HTTP, cloud, clock). Internal collaborators stay plain and real in tests.
mod-internals-unreachable
should-fixdraftmaintainability
Applies when: A module has an intended public surface and internal files or symbols.
Rule. Make a module's internals unimportable from outside it, and have a check fail the build when a caller reaches in.
Why. Anything a caller can import becomes the interface. A naming convention such as a leading underscore stops no one, and the module can no longer change its internals without breaking callers.
FAIL
from billing.internal.tax_tables import RATES # nothing stops this
PASS
billing/__init__.py exports calculate_tax; import-linter contract forbids billing.internal.* from outside billing
Counterpoint. "A boundary check is overhead for a small repo." One lint contract is cheap, and the first outside import of an internal costs more. For a single-file module, there is no inside to protect, so mark N/A.
Applies when: The codebase has separable transport, logic, and outbound-boundary code.
Rule. Make dependencies point inward: surface, then logic, then boundary.
Why. Once transport types leak into logic, you cannot reuse or test the logic without the transport, and a change to the API shape ripples into the rules. Name the layers for what they do (surface, logic, boundary).
controller -> service -> repository (imports only along this arrow)
service takes plain domain types
Counterpoint. "Three layers is ceremony for a CRUD app." For a ten-endpoint app, yes; fold them. The check applies once you have real logic. It asks only that dependencies not point outward, not that you build all three folders.
Tool gate: import-linter or similar
mod-mock-at-boundaries-only
should-fixdrafttesting
Applies when: A test uses mocks, patches or fakes.
Rule. Mock only at the process boundary (HTTP, DB, cloud, clock, I/O).
Why. Mocks of internals pin the implementation, so any refactor breaks tests that prove nothing. A fake looser than the real client passes tests that the real backend rejects. Keep internal objects real, make a fake accept only what the real client accepts, and assert whole outcomes. See chg-agent-mock-asserting-tests for tests that must fail when behaviour breaks.
FAIL
mailer.send.assert_called_once() # proves a call, not what was sent
class FakeStore: def get(self, **_): ... # accepts anything
PASS
assert outbox.sent == [Email(to="ana@example.com", template="welcome")]
class FakeStore: def get(self, key: str): ... # real signature
Counterpoint. "Isolated unit tests need every collaborator mocked." That tests the wiring you wrote, not the behaviour. Prefer a stub when behaviour is simple, and mock only what crosses the process edge.
mod-no-import-side-effects
blockdrafttestingmaintainability
Applies when: A module does anything at import or load time other than defining names.
Rule. Keep I/O at the edges: importing a package must not touch the network, read files, or read the environment.
Why. Import-time effects make tests slow and order-dependent, break tooling that merely imports the module, and fail in places that had no reason to need a network.
FAIL
CLIENT = HttpClient(os.environ["API_URL"]) # at import
DATA = json.load(open("data.json"))
Counterpoint. "Loading once at import is fast." Load once at startup, explicitly, where the caller controls it and tests can substitute it.
mod-no-logic-in-constructors
should-fixdraftcorrectnesstesting
Applies when: A constructor does work beyond assigning its arguments.
Rule. Assign only in constructors, and put I/O, parsing, and heavy computation in a named factory.
Why. A constructor with side effects makes every instantiation, including in tests, read a file or call an API. You need a real resource or a patch just to build the object.
FAIL
class PriceList:
def __init__(self, path):
self._prices = json.loads(path.read_text())
Counterpoint. "A factory per class is ceremony." For a value object with no I/O, there is nothing to factor out. The rule targets constructors that read, call, or parse. With the factory, the test builds the object in one line.
mod-no-module-mutable-state
should-fixdraftcorrectnesstesting
Applies when: A module defines a variable that changes after load: a list, dict, counter, cache, client or singleton.
Rule. Keep no mutable state at module level.
Why. Hidden global state leaks between tests, between requests, and between threads, and its order of mutation is unreadable. A frozen, load-once constant such as settings is the exception. A singleton used to dodge injection is not.
class Loader:
def __init__(self, cache): self._cache = cache
Counterpoint. "A module global is the simplest cache." It is also unbounded and shared by everything. If you need one, bound it (LRU or TTL), inject it, and tie its lifetime to what it caches.
mod-no-utils-grab-bag
should-fixdraftmaintainability
Applies when: The diff adds or grows a module named utils, helpers, common, or misc.
Rule. Put a helper in the module that owns the concept, or in a new module named after the concept.
Why. A grab-bag has no owner and no cohesion. It becomes the dependency of everything and the place where unrelated code accretes.
Counterpoint. "Tiny helpers do not deserve a file." Then inline them where they are used (YAGNI applies). When one is shared, naming its concept is the work that makes it findable.
Applies when: A module exposes a public function, class or re-export.
Rule. Expose domain types in a module's public interface, and keep transport, storage and framework types behind it.
Why. A public signature that returns an ORM row or a protobuf message ties every caller to that library and schema. A storage change then ripples through code that never cared about storage.
FAIL
def get_user(id: str) -> UserRow: ... # SQLAlchemy model
from proto.user_pb2 import User # re-exported as the API
Counterpoint. "Mapping costs a copy and a class." For a thin pass-through with no second consumer, that cost can exceed the benefit; say so in the review. The rule bites when the type crosses a package or team boundary.
Applies when: A shared helper or base class has gained flags or conditionals so different callers can bend it.
Rule. Inline an abstraction that has grown flags for its callers, then re-split it.
Why. Extract an abstraction when real call sites need it (mod-yagni). A wrong one is harder to remove than the duplication it replaced, because each new caller adds a branch until nobody can say what the code is for.
Counterpoint. Sources disagree. Ousterhout lists repetition as a red flag (one fact in many places), and the AHA/Sandi Metz line prefers duplication to a wrong abstraction. They fit together: dedupe knowledge (a rule, a format, a constant), wait on shape, and extract only when the second real caller shows what is shared.
Applies when: The change introduces or reuses names for domain concepts.
Rule. Use one name for one concept across the codebase.
Why. If customer, client, and account all mean the same thing, readers wonder whether they differ. A name that misdescribes behaviour is worse than a vague one. Follow the project glossary, and never choose a name that says something the code does not do.
FAIL
get_customer(); load_client(); fetch_account() # same entity
def profile_data(df): ... # inspects column types, not performance
Counterpoint. "Names are bikeshedding." Reviewers should not argue taste. They should check that the name is consistent and true. Vague names (names that could mean many things) are a design smell in Ousterhout's list. Do not rename wholesale in a feature change; make the rename a separate change.
Applies when: A module wraps protocol, parsing or business logic and offers it to callers that may be sync or async.
Rule. Keep logic in plain synchronous functions with no I/O, and add async wrappers at the edge only.
Why. An async-only API forces every caller into an event loop, doubles testing, and makes accepting work so cheap that overload goes unnoticed. A synchronous core is tested without a loop and serves both worlds.
Counterpoint. "Everything here is async anyway." That is fine for the shell. The test is whether the logic needs I/O to run. If it does not, it should not need await either.
Counterpoint. "Zero as the common default saves a field on the wire." The saving is a byte on a field already at its most common value, and the cost is a silent wrong state. Published enums keep their numbers, so the fix for an existing enum is a new unspecified name only if no value has to be renumbered.
Applies when: The code adds a subclass or an implementation of an interface.
Rule. Make a subtype pass its parent's tests: accept every input the parent accepts, promise at least what it promises, and keep its invariants.
Why. Callers hold the parent type. A subtype may weaken preconditions and strengthen postconditions, never the reverse, and must keep the parent's invariants. The parent's black-box tests are the cheapest way to check that. A subclass that rejects valid input or weakens a result breaks them, and the failure appears far from the subclass.
FAIL
class ReadOnlyFile(File):
def write(self, data):
raise NotImplementedError # parent accepts any bytes
PASS
class ReadOnlyFile(Readable): ... # separate type; File(Readable, Writable)
# or: the parent's black-box tests run unchanged against ReadOnlyFile
Counterpoint. "Inheritance is for sharing code." Then share it by composition. A subclass that ignores most of what it inherits (mod-fowler-smells) is a sign that the hierarchy is wrong.
Tool gate: Run the parent's test suite against every subclass; mypy flags incompatible overrides.
Applies when: The application has a main module, an entry point, or a bootstrap file.
Rule. Keep the entry module to wiring (bootstrap, configuration, lifespan) and put no business logic in it.
Why. Logic in the entry module cannot be imported by tests or other entry points without starting the app.
FAIL
main.py: defines routes, validates, computes prices, starts server
PASS
main.py: build settings, wire dependencies, mount routers, start
Counterpoint. "It is only 20 lines." It will not stay 20. The rule gives the next feature an obvious home that is not main.
mod-validate-at-boundary-once
should-fixdraftcorrectnessmaintainability
Applies when: Input checks appear in more than one layer, or hand-rolled checks appear on a dict.
Rule. Validate at the boundary, once, with a schema or model, so that inner layers receive typed, already-valid data.
Why. The same check copied into three layers drifts until nobody knows which one is authoritative.
FAIL
def create_order(body: dict):
if not body.get("items"): raise ...
if body.get("currency") not in {"EUR", "USD"}: raise ...
PASS
class CreateOrder(Schema):
items: list[Item] # min 1
currency: Currency # enum, unknown fails at parse
Counterpoint. "Defence in depth means validating everywhere." At trust boundaries, yes: every external input. Inside, repeated validation hides who owns the rule. Use types to carry "already valid" inward.