Checks one file or function: names, errors, concurrency, and security at the call level. Use it for this input: file / function. 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
fn-aria-role-has-state-and-keysshould-fixaccessibilityWhen a native element cannot do the job, give the custom widget the state attribute and the keys its role promises.
fn-colour-not-sole-cueshould-fixaccessibilityusabilityPair every colour that carries meaning with a second cue: text, an icon, a shape, or an underline.
fn-comments-explain-whyshould-fixmaintainabilityWrite comments that give a reason, a constraint, or a trade-off; rename before commenting, and never restate what the next line does.
fn-confine-file-pathblocksecurityResolve the final path and check that it stays inside the intended base directory before using it.
fn-csprng-for-secretsblocksecurityDraw every security-relevant random value from the platform CSPRNG.
fn-destructive-confirm-or-undoshould-fixusabilityMake a destructive action reversible with undo, or ask the user to confirm it before it runs.
fn-dialog-focus-managedblockaccessibilityusabilityWhen a modal opens, move focus into it and keep Tab inside it. Make Escape close it.
fn-error-says-what-and-howshould-fixusabilityaccessibilityShow each validation error next to its field, in plain words that name what is wrong and how to fix it.
fn-fetch-only-needed-datashould-fixperformanceFilter, count, and aggregate in the query, and select only the columns the code uses.
fn-focus-indicator-visibleblockaccessibilityusabilityKeep a visible focus indicator on every focusable element. Never set outline: none without a replacement.
fn-form-control-has-labelblockaccessibilityusabilityTie every form control to a visible <label>, with for and a matching id or by wrapping it. A placeholder is not a label.
fn-heading-structureshould-fixaccessibilityusabilityMark every heading with a real <h1> to <h6> element, not a styled div, span, or p.
fn-img-alt-textshould-fixaccessibilityGive every <img> an alt attribute: text that serves the image's purpose, or alt="" when the image is decorative.
fn-independent-work-concurrentshould-fixperformancecorrectnessStart independent I/O together and wait for all of it; run calls in sequence only when one needs the result of another.
fn-join-dont-concat-in-loopshould-fixperformanceBuild a string from a loop with a builder or a join, never with repeated +=.
fn-jwt-verify-pinnedblocksecurityVerify the signature against a fixed list of accepted algorithms, and check iss and aud, before reading any claim.
fn-keep-input-on-errorshould-fixusabilityAfter a failed validation or submit, show the form again with every value the user entered, correct or not.
fn-native-button-or-linkblockaccessibilityusabilityUse <button> for an action and <a href> for navigation. Do not attach a click handler to a div, span, or a with no href.
fn-no-blocking-call-in-async-codeblockperformancecorrectnessNever make a blocking I/O call or run long CPU work directly in async code; use the async API or hand the work to a thread or worker.
fn-no-mass-assignmentblocksecurityBind request data to a DTO or an explicit field allowlist, never straight onto the model.
fn-no-positive-tabindexshould-fixaccessibilityusabilityWrite the DOM in reading order and never set tabindex above 0.
fn-no-raw-html-sinkblocksecurityWrite untrusted text through an encoding sink (textContent, auto-escaped template), never through a raw HTML sink.
fn-no-shell-with-inputblocksecurityStart processes with the program and each argument as separate list items, and never through a shell string.
fn-object-level-authzblocksecurityCheck that the caller may act on the specific record before returning or changing it.
fn-password-slow-hashblocksecurityHash passwords with Argon2id (or scrypt, bcrypt, PBKDF2) and a per-password salt from the library.
fn-reduced-motion-respectedshould-fixaccessibilityusabilitySwitch off or replace large, repeating, or interaction-triggered motion when prefers-reduced-motion: reduce is set.
fn-regex-compiled-onceshould-fixperformanceCompile a regular expression once, at module or startup level, and reuse it.
fn-rerun-safe-after-crashblockreliabilityMake a state-changing operation give the same result on a second run and after a crash at any step, by deriving each step from current state and making leftovers recognisable as stale.
fn-response-fields-explicitshould-fixsecuritycompatibilityReturn a response type that lists each field, never the stored model or a generic dump of it.
fn-status-announced-live-regionshould-fixaccessibilityusabilityAnnounce an async status message through a live region (role="status" or role="alert") that is already in the markup before its text changes.
fn-target-sizeshould-fixaccessibilityusabilityMake each pointer target at least 24 by 24 CSS pixels, or space it so that a 24 px circle centred on it touches no other target and no other undersized target's circle.
fn-tls-verify-onblocksecurityLeave certificate and hostname verification on in every environment that ships.
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
fn-aria-role-has-state-and-keys
should-fixstableaccessibility
Applies when: A custom widget sets role or an aria-* attribute because no native element fits.
Rule. When a native element cannot do the job, give the custom widget the state attribute and the keys its role promises.
Why. A role only tells assistive technology what the element is. It adds no keyboard behaviour, and it says nothing about state. A role="switch" with no aria-checked is announced as a switch of unknown state. Wrong ARIA is worse than none, because it gives a screen-reader user a false picture. The widget also needs an accessible name, from its text or a label, or it is announced by role alone.
FAIL
<button type="button" role="switch" onClick={toggle}>Dark mode</button>
// no aria-checked: announced as a switch of unknown state
PASS
<button type="button" role="switch" aria-checked={on} onClick={toggle}>
Dark mode
</button>
// a native <button> already takes Tab, Enter, and Space
Counterpoint. "ARIA patterns are in the guide, so we copy them." Copy the keyboard table as well as the attributes. For a plain toggle, a button with aria-pressed is enough, and the label should stay the same when the state changes.
Tool gate: ESLint jsx-a11y/role-has-required-aria-props; axe aria-required-attr. Neither checks that the keys work.
Applies when: Styles or markup show an error, a required field, a status, or a link using colour.
Rule. Pair every colour that carries meaning with a second cue: text, an icon, a shape, or an underline.
Why. Colour-blind users, glare, greyscale mode, and a screen reader all remove colour. A red border on a bad field, or a link that differs from body text only by hue, tells those users nothing.
FAIL
.field.error { border-color: red; }
.status-ok { color: green; }
a { color: #0a58ca; text-decoration: none; } /* no underline, no hover or focus cue */
PASS
<input aria-invalid="true" aria-describedby="e1" style="border: 2px solid #b42318">
<p id="e1" class="error"><svg aria-hidden="true">…</svg> Error: enter a date such as 27/03/2026</p>
<a href="/terms" style="text-decoration: underline">terms</a>
Counterpoint. "Colour is the quickest cue, and the icon clutters the design." Colour can stay as the fast cue. The rule asks for a second one, not for removing the first. A text prefix such as "Error:" costs a few characters. A link in body text can also pass with 3:1 contrast against the text around it plus a non-colour cue on hover and focus. The underline is the simpler fix.
Tool gate: none; view the page in greyscale, or run a browser colour-vision emulation.
Applies when: A comment sits next to code, or the code needs one to be understood.
Rule. Write comments that give a reason, a constraint, or a trade-off; rename before commenting, and never restate what the next line does.
Why. A restating comment rots silently and adds nothing. A missing reason makes the next person delete the code that mattered.
FAIL
counter += 1 # increment the counter
retry = False # see the PR, currently disabled
PASS
# Business decision: activation and entitlement update in one transaction,
# so a user never has access without an active subscription.
# TODO: remove when billing emits entitlement events itself.
Counterpoint. "Good names make comments unnecessary." Names carry what. They cannot carry why a branch exists, a unit, or what callers may rely on, and a missing reason costs more than a slightly stale one. Rename first, then comment what the name cannot say. A stale comment is a defect either way. The test is whether the comment adds information that the code cannot carry.
Applies when: Code opens, reads, writes or deletes a file whose name or path includes request data.
Rule. Resolve the final path and check that it stays inside the intended base directory before using it.
Why. A name like ../../etc/passwd, or its percent-encoded form, walks out of the upload folder. Stripping ../ fails against ....// and double encoding. The reliable check works on the resolved path. Better still, map a user choice to an ID and build the path in code.
p = (UPLOADS / request.args["name"]).resolve()
if not p.is_relative_to(UPLOADS.resolve()): abort(400)
return send_file(p)
Counterpoint. "We already strip ..." String stripping is a blocklist and gets bypassed. Resolve first, compare second. Resolving catches a symlink that already points outside, but a symlink swapped in between the check and the open still escapes. For trees that untrusted users can write to, use a rooted open such as Go's os.OpenInRoot, which refuses any escape at open time.
Tool gate: CodeQL py/path-injection (CWE-22) or the equivalent query for your language.
Applies when: Code generates a token, session ID, password-reset code, API key, nonce or IV.
Rule. Draw every security-relevant random value from the platform CSPRNG.
Why. A general-purpose PRNG is predictable from a few outputs, so an attacker who sees two tokens can guess the next. UUIDv1 is time-based and also not secret. The API call is the whole fix, so it is cheap to demand.
token = secrets.token_urlsafe(32) # os.urandom under the hood
# JavaScript: crypto.getRandomValues(new Uint8Array(32))
Counterpoint. "Math.random is fine for a short-lived code." Short-lived codes still get brute-forced, and predictable ones need no brute force. Use the CSPRNG for anything an attacker gains from guessing.
Tool gate: Bandit B311 (random) for Python; elsewhere a grep for Math.random and rand() in code that builds tokens or keys.
Applies when: A handler deletes user data, spends money, or sends something that cannot be recalled.
Rule. Make a destructive action reversible with undo, or ask the user to confirm it before it runs.
Why. People click the wrong thing. If the action cannot be reversed and nothing stops it, one slip deletes their work. Undo costs a successful user nothing, while a dialog on every click trains them to click through. A dialog works best when its buttons name the outcome, such as "Delete project" and "Cancel", because "OK" and "Are you sure?" say nothing about what happens next.
async function remove() {
await api.softDelete(id);
toast("Project deleted", { action: { label: "Undo", onClick: () => api.restore(id) } });
}
// irreversible case: a dialog "Delete project “Q3 plan”?" with buttons "Delete project" and "Cancel"
Counterpoint. "Confirm everything to be safe." Frequent prompts are dismissed without reading, so they protect nothing. Use undo for common actions and reserve the dialog for rare, irreversible ones. Do not make Cancel the default button.
Tool gate: none; a test that triggers the action and asserts a confirm step or an undo path exists.
Applies when: A component opens a modal dialog, drawer, or menu that covers the page.
Rule. When a modal opens, move focus into it and keep Tab inside it. Make Escape close it.
Why. Without it, a keyboard user keeps tabbing through the page behind the dialog, or gets stuck inside it with no exit. When it closes, return focus to the control that opened it. Focus that falls to the top of the page makes them start over.
Counterpoint. "Our library handles it." Check, because a library that traps focus but never restores it is common. Prefer the native <dialog> with showModal(), which makes the page inert and closes on Escape. A dialog built from generic elements must recreate each of those.
Tool gate: none; a component test that opens the dialog, presses Tab and Escape, and asserts document.activeElement.
Applies when: Code builds the message shown when user input fails validation.
Rule. Show each validation error next to its field, in plain words that name what is wrong and how to fix it.
Why. "Invalid input" or an error code leaves the user to guess which field and what to change. A message at the top of a long form, or in a toast that fades, sends a magnifier user searching. Naming the fix is the shortest path to a valid form.
<label for="dob">Date of birth</label>
<p id="dob-err"><span class="visually-hidden">Error:</span>
Enter a date of birth in the past, such as 27 3 1986</p>
<input id="dob" name="dob" aria-invalid="true" aria-describedby="dob-err">
Counterpoint. "Detailed messages leak information." For a login, that is true: say "Email or password is wrong". For ordinary fields, the specific message is the fix. Hold back the suggestion only where it would weaken security.
Tool gate: none; a rendered check that submits each invalid field.
Applies when: A function queries a database or an API and then filters, counts, or sums the result in application code.
Rule. Filter, count, and aggregate in the query, and select only the columns the code uses.
Why. Loading every row to count it, or every column to read one, moves data across the network and through memory for nothing. SELECT * also rules out an index-only scan.
FAIL
total = sum(o.amount for o in Order.objects.all() if o.status == "paid")
n = len(Order.objects.filter(user=u)) # loads every row
emails = [u.email for u in User.objects.all()] # loads every column
PASS
total = Order.objects.filter(status="paid").aggregate(Sum("amount"))["amount__sum"]
n = Order.objects.filter(user=u).count()
emails = User.objects.values_list("email", flat=True)
Counterpoint. "Pushing logic into the database makes it the bottleneck." It does for formatting and business rules. Filtering and aggregation are what the database is built for, and moving them out usually ships far more data than it saves.
Applies when: Styles or script change outline, :focus, or :focus-visible.
Rule. Keep a visible focus indicator on every focusable element. Never set outline: none without a replacement.
Why. A keyboard user finds their place by the focus ring. Remove it and they navigate blind. A sticky header that covers the focused link has the same effect once they tab down the page. scroll-padding-top on html, as tall as the header, keeps the focused element in view.
Counterpoint. "Designers dislike the ring on mouse clicks." :focus-visible shows the ring for keyboard focus and hides it for most mouse clicks. Restyle the ring if you must. Do not remove it.
Tool gate: Stylelint declaration-property-value-disallowed-list with outline: ["none", "0"].
Applies when: Markup or a component renders an input, a select, or a textarea.
Rule. Tie every form control to a visible <label>, with for and a matching id or by wrapping it. A placeholder is not a label.
Why. A screen reader announces a bare field as "edit text". Voice-control users cannot say its name. A label also enlarges the click target. A placeholder vanishes when the user types, so it fails every user who looks away and comes back.
Counterpoint. "The design shows a floating placeholder label." A floating label is a real label if it stays visible and is wired to the input. Use aria-label only where the purpose is already visible, such as a search field next to a Search button.
Tool gate: ESLint jsx-a11y/label-has-associated-control and axe label.
Applies when: Markup or a component renders a title or a section heading.
Rule. Mark every heading with a real <h1> to <h6> element, not a styled div, span, or p.
Why. Screen-reader users jump from heading to heading to scan a page. A div styled as a heading is invisible to that. Pick the level from the outline, not from the size you want: a skipped level, such as h2 to h4, makes the outline look as if a section is missing.
<h1>Account</h1>
<h2>Billing</h2>
<h3>Invoices</h3>
<!-- size comes from CSS: h2 { font-size: 1rem } -->
Counterpoint. "The design needs a small h2." That is a CSS problem. Keep the level that matches the outline and set the size in the stylesheet. Jumping back up a level, such as h4 to h2, is fine when a section ends.
Tool gate: axe p-as-heading (experimental) flags a bold paragraph styled as a heading; axe heading-order flags skipped levels.
Applies when: Markup or a component renders an image, an icon, or a chart.
Rule. Give every <img> an alt attribute: text that serves the image's purpose, or alt="" when the image is decorative.
Why. A screen reader has nothing to say about an image with no alt, and often reads the file name instead. An informative image with an empty alt hides its content. A decorative image with a long alt adds noise to every page view.
<img src="/img/q3-revenue.png" alt="Revenue grew from 4.1 to 5.3 million in Q3">
<img src="/img/divider.png" alt="">
<button><img src="/img/trash.svg" alt="Delete draft"></button>
Counterpoint. "A linter already checks alt." It checks that the attribute exists, not that the text fits the image or the context. Review the text itself. For a chart, give a short alt plus the data nearby as text.
Tool gate: ESLint jsx-a11y/alt-text and axe image-alt catch a missing alt. Only a person can judge whether the text is accurate.
Counterpoint. "Parallel calls load the dependency harder and complicate errors." Cap fan-out with a semaphore or pool, and use a task group so one failure cancels the rest. A deliberate order, for rate limits or ordering, is a valid reason. Write it down.
Applies when: A function builds a string from pieces in a loop whose length depends on input.
Rule. Build a string from a loop with a builder or a join, never with repeated +=.
Why. Strings are immutable in Python, Go, Java, and .NET, so each += copies everything built so far. Over n pieces the total cost grows with the square of the output length.
FAIL
out = ""
for row in rows:
out += f"{row.id},{row.name}\n"
PASS
out = "".join(f"{row.id},{row.name}\n" for row in rows)
Counterpoint. "CPython optimises += in place." Sometimes: only in CPython, and only while nothing else holds a reference to the string. Code that leans on it turns quadratic on other runtimes. A small fixed number of pieces is fine with + or an f-string, which read better. The rule covers loops over input-sized data.
Tool gate: Linter rule for string concatenation in a loop (for example pylint consider-using-join).
Applies when: Code accepts a JWT as proof of identity or permission.
Rule. Verify the signature against a fixed list of accepted algorithms, and check iss and aud, before reading any claim.
Why. If the verifier takes the algorithm from the token header, an attacker picks none or turns an RSA public key into an HMAC secret. Claims read before verification are attacker text. Pinning the algorithm and binding each key to one algorithm closes both. Also require exp, so a stolen token stops working.
FAIL
claims = jwt.decode(token, options={"verify_signature": False})
claims = jwt.decode(token, key) # PyJWT 1.x: algorithm taken from the header
Counterpoint. "Our gateway already verifies the token." Then the service trusts a header that any internal caller can forge. Verify at each trust boundary, or prove that only the gateway can reach the service.
Tool gate: CodeQL js/jwt-missing-verification; a grep for verify_signature": False and for jwt.decode( calls with no algorithms=.
Applies when: A form handler or component re-renders after a failed validation or a failed submit.
Rule. After a failed validation or submit, show the form again with every value the user entered, correct or not.
Why. A form that wipes its fields turns one typo into ten minutes of retyping, and some users give up. The user fixes one field, not the whole page.
FAIL
async function submit(e) {
e.preventDefault();
const res = await post("/signup", values);
if (!res.ok) { setErrors(res.errors); setValues(EMPTY); }
}
PASS
async function submit(e) {
e.preventDefault();
const res = await post("/signup", values);
if (!res.ok) setErrors(res.errors); // values stay as typed
}
Counterpoint. "Clear a password or card number on error for safety." That is a fair exception for secrets, and the message should say so. Everything else the user typed stays.
Tool gate: none; a test that submits an invalid form and asserts the entered values are still there.
Applies when: A component handles a click or tap, or navigates.
Rule. Use <button> for an action and <a href> for navigation. Do not attach a click handler to a div, span, or a with no href.
Why. A div is not in the tab order, has no role, and ignores Enter and Space. A keyboard or screen-reader user cannot reach or operate it. A fake link also loses open-in-new-tab and copy-link. A native element gives all of that with no code.
Counterpoint. "Native buttons are hard to style." They are not: reset border, background, and font on the element. Rebuilding the keyboard, role, and focus behaviour by hand costs more and fails more often. Where a custom widget is unavoidable, follow the WAI-ARIA Authoring Practices pattern for it.
Tool gate: ESLint jsx-a11y/click-events-have-key-events and jsx-a11y/no-static-element-interactions; jsx-a11y/anchor-is-valid for href="#".
Applies when: Code runs on an event loop or an async runtime (asyncio, Node.js, Tokio, ASP.NET async handlers).
Rule. Never make a blocking I/O call or run long CPU work directly in async code; use the async API or hand the work to a thread or worker.
Why. An event loop runs one callback at a time. A time.sleep, a synchronous HTTP call, or a big hash inside a handler stalls every other request on that loop, not just the slow one.
FAIL
async def handler(req):
data = requests.get(url).json() # blocks the loop
digest = hashlib.pbkdf2_hmac("sha256", pw, salt, 600_000)
Counterpoint. "This call is fast enough." A short read from a local SSD can be quicker synchronously. Measure it with asyncio debug mode or the runtime's equivalent. A call that crosses the network, or that waits on anything outside the process, is never in that class.
Tool gate: Linter rule for blocking calls in async functions (for example ruff ASYNC); asyncio debug mode flags slow callbacks.
Applies when: A handler copies request fields into a model, ORM entity or settings object.
Rule. Bind request data to a DTO or an explicit field allowlist, never straight onto the model.
Why. When the model has is_admin, balance or owner_id, a client that adds those keys to the JSON body sets them. A blocklist fails the day someone adds a new sensitive column. The allowlist still needs the object-level check from fn-object-level-authz.
FAIL
user = User(**request.json) # body {"name": "a", "is_admin": true}
user.save()
PASS
class UserIn(BaseModel): # only editable fields exist here
name: str
user = User(**UserIn(**request.json).model_dump())
Counterpoint. "A DTO per endpoint is boilerplate." Generate DTOs from the API schema. The boilerplate costs less than one privilege escalation.
Tool gate: A custom Semgrep rule for Model(**request.json); tests that post extra fields such as is_admin and assert they are ignored or rejected.
Applies when: Code turns bytes or text from outside the process into objects.
Rule. Parse untrusted input with a data-only format, never with a native object deserialiser.
Why.pickle, ObjectInputStream, BinaryFormatter and full-power YAML loaders can run attacker-chosen code while they load, so validating after the call is too late. JSON cannot instantiate arbitrary classes. Validate the parsed data against a schema afterwards, as the PASS does.
Counterpoint. "The data comes from our own queue." Anyone who can write to the queue can run code on every consumer, so the queue is a trust boundary. Signing the payload, or a Java serialisation filter that allowlists classes, narrows the risk while you migrate. The call still fails this check until it reads a data-only format.
Tool gate: Bandit B301 (pickle) and B506 (yaml_load), CodeQL py/unsafe-deserialization; a grep for ObjectInputStream, BinaryFormatter and unserialize elsewhere.
Applies when: Markup sets tabindex, or a layout reorders content with CSS.
Rule. Write the DOM in reading order and never set tabindex above 0.
Why. A positive tabindex jumps those elements to the front of the tab order, ahead of everything else on the page. Each new control then needs a new number, and the order stops matching what the user sees. CSS that moves items visually without moving them in the DOM does the same thing.
<input name="first">
<input name="last">
<button>Save</button>
<!-- tabindex="0" only to add a custom widget to the order; tabindex="-1" to focus it from script -->
Counterpoint. "The visual layout puts the sidebar first, but the main content comes first in the DOM." Reorder the DOM to match, or accept the difference only if the order still makes sense to a keyboard user. Do not patch it with numbers.
Applies when: Code writes a string into a page, a template or a rendered model-output view.
Rule. Write untrusted text through an encoding sink (textContent, auto-escaped template), never through a raw HTML sink.
Why. One innerHTML of a username or of model output runs the attacker's script with the victim's session. Encoding must match the context: body, attribute, script string and URL each differ. Framework escape hatches such as dangerouslySetInnerHTML have the same effect.
el.textContent = comment.body;
<div>{reply}</div> // escaped by the framework
// user-authored HTML: DOMPurify.sanitize(html), nothing edited afterwards
Counterpoint. "We need to render rich text." Then sanitise with a maintained library such as DOMPurify at render time and add a CSP as a second layer. A CSP alone is not a fix, because it is defence in depth.
Tool gate: ESLint (no-unsanitized/property, no-unsanitized/method, react/no-danger) and template-engine autoescape checks.
Applies when: Code starts a process and some part of the command comes from a request, a file or model output.
Rule. Start processes with the program and each argument as separate list items, and never through a shell string.
Why. A shell reads ;, | and $() as commands, so a filename of a; rm -rf ~ runs. Argument arrays skip the shell. The values still need validation, since an argument beginning with - becomes an option; end options with -- where the tool allows it.
subprocess.run(["convert", upload_path, "out.png"], check=True) # upload_path is a server-generated name
subprocess.run(["git", "log", branch], check=True) # branch checked against an allowlist
Counterpoint. "We need shell features like pipes." Chain two subprocess calls in code, or use the library that does the job. If a shell is unavoidable, build the string only from constants.
Tool gate: Bandit B602 (subprocess_popen_with_shell_equals_true) and B605 (start_process_with_a_shell) for Python.
Applies when: A handler loads, changes or deletes a record by an ID that comes from the request.
Rule. Check that the caller may act on the specific record before returning or changing it.
Why. A login proves who the caller is, not which records they own. Without a per-object check, changing id=41 to id=42 reads or edits another user's data (IDOR, also called BOLA). Random IDs only hide the problem.
FAIL
order = db.get(Order, request.args["id"]) # any logged-in user, any order
return order.to_json()
PASS
order = db.get(Order, request.args["id"])
if order is None or order.owner_id != user.id: raise NotFound() # same error for both
return order.to_json()
Counterpoint. "The IDs are UUIDs, nobody can guess them." A UUID leaks through logs, links and shared screenshots, and it is not an access check. Keep it as a second layer. Scope the query itself (WHERE owner_id = :me) so the check cannot be forgotten on the next endpoint.
Tool gate: Authorization tests that call each endpoint as a second user; no static gate catches a missing check.
Applies when: Code stores or verifies a user password.
Rule. Hash passwords with Argon2id (or scrypt, bcrypt, PBKDF2) and a per-password salt from the library.
Why. A fast hash such as SHA-256 lets an attacker with the database try billions of guesses a second. A slow, salted, memory-hard hash makes each guess cost real time. A common baseline is Argon2id with at least 19 MiB of memory and 2 iterations. Check the current numbers when you review.
ph = argon2.PasswordHasher() # salt and parameters stored in the hash string
stored = ph.hash(password)
ph.verify(stored, attempt) # rehash when check_needs_rehash()
Counterpoint. "Argon2 is expensive at login." That cost is the defence; tune it to about a second on production hardware. Where FIPS compliance rules Argon2 out, PBKDF2-HMAC-SHA256 with a high iteration count is the accepted fallback.
Tool gate: Grep or SAST rule for md5, sha1, sha256 near a password identifier.
Applies when: Styles or script add a transition, an animation, a parallax effect, or autoplay motion.
Rule. Switch off or replace large, repeating, or interaction-triggered motion when prefers-reduced-motion: reduce is set.
Why. Scaling, panning, and parallax can trigger nausea and dizziness for people with vestibular disorders. They have already told their device so. A page that ignores the setting makes them leave.
Counterpoint. "Motion carries meaning, such as a loading spinner." Keep what the user needs and make it gentler: swap movement for a fade. The rule is about decorative and large movement, not about every pixel that moves.
Tool gate: Stylelint a11y/media-prefers-reduced-motion from the stylelint-a11y plugin. A browser run with reduced motion emulated.
Applies when: Code builds a regular expression or similar compiled matcher inside a loop or a function that runs per request.
Rule. Compile a regular expression once, at module or startup level, and reuse it.
Why. Compiling costs far more than matching and throws away reusable allocations. Inside a loop or a per-request function, the cost repeats on every call.
Counterpoint. "The library caches patterns." Python and .NET keep recently used patterns, so a few static calls are fine. A pattern built from input or an unbounded set of patterns defeats the cache.
Tool gate: Linter rule for a regex constructor in a loop, where the language has one.
Applies when: A function or job changes state and can be run twice, or can be killed partway.
Rule. Make a state-changing operation give the same result on a second run and after a crash at any step, by deriving each step from current state and making leftovers recognisable as stale.
Why. Jobs get retried and processes get killed. An append, a counter bump, or a lock file with no owner and time leaves the next run unable to tell finished work from half-done work. Record "done" only after the last step succeeds, so a crash leaves the work visibly pending.
FAIL
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, acct)) # a retry applies it twice
open("out.json", "w").write(data) # a crash leaves a truncated file
PASS
cur.execute("INSERT INTO applied_ops (op_id) VALUES (%s) ON CONFLICT DO NOTHING", (op_id,))
if cur.rowcount == 1: # first run of this operation; same transaction as the update
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, acct))
tmp = path.with_suffix(".tmp")
tmp.write_text(data)
os.replace(tmp, path) # the reader sees the old file or the new one, never half of one
Counterpoint. "Retries are rare." Crashes are not. For API calls that cross a network, svc-idempotency-key covers the key-based form of this rule.
Applies when: A handler returns a stored object or entity to the client.
Rule. Return a response type that lists each field, never the stored model or a generic dump of it.
Why. A model gains password_hash, internal_notes or email and every endpoint that serialises it leaks the new field silently. Callers also start to depend on whatever is sent, so removing a field later is a breaking change. An explicit response type makes each exposed field a decision someone reviewed. The write side is fn-no-mass-assignment.
FAIL
return jsonify(user.__dict__)
return user_model.to_dict() # all columns
PASS
class UserOut(BaseModel): id: int; display_name: str
return UserOut.model_validate(user)
Counterpoint. "A response type per endpoint duplicates the model." Derive it from the model with a field list. The duplication is one line per field and shows up in the diff when a field is added.
Tool gate: Response-schema tests that fail on unlisted fields.
Applies when: Script shows a message after an async action without moving focus, such as "Saved", a result count, or an error.
Rule. Announce an async status message through a live region (role="status" or role="alert") that is already in the markup before its text changes.
Why. A sighted user sees "Saved" appear. A screen-reader user hears nothing, because focus did not move. Most readers announce a change only in a region that existed first, so a region created together with its message is often skipped.
Counterpoint. "Move focus to the message instead." Use that for a blocking error after submit, where the user must act. For a passive result, moving focus steals the user's place. Keep assertive and role="alert" for errors, because they interrupt speech.
Tool gate: none; a screen-reader pass, or a test that the region exists with a role before the text changes.
Applies when: Styles size a button, icon button, checkbox, or link that a pointer or finger operates.
Rule. Make each pointer target at least 24 by 24 CSS pixels, or space it so that a 24 px circle centred on it touches no other target and no other undersized target's circle.
Why. Small, tight controls cause mis-taps for anyone with a tremor, a large thumb, or a bumpy train. A mis-tap on "Delete" next to "Edit" costs more than a mis-tap on a menu item.
Counterpoint. "Inline links and dense tables are exempt." Targets inside a sentence are, and so are controls with an equivalent control that is big enough. A row of toolbar icons is neither, and nor are the action buttons on a table row. Touch platforms often ask for 44 px or more, so treat 24 px as the floor, not the goal.
Tool gate: axe target-size (WCAG 2.2); a rendered check for the spacing exception.
Applies when: Code makes an outbound HTTPS, database or message-broker connection.
Rule. Leave certificate and hostname verification on in every environment that ships.
Why. With verification off, any on-path attacker presents a certificate and reads or changes the traffic. The flag is usually added to silence a dev-box error and then ships. Fix the trust store or pin a private CA instead.
requests.get(url, verify="/etc/ssl/company-ca.pem") # private CA added, check stays on
Counterpoint. "It is an internal network." Internal links get sniffed after one compromised host. A private CA gives you the same convenience with the check intact.
Tool gate: Bandit B501 (request_with_no_cert_validation), CodeQL py/request-without-cert-validation; a grep for rejectUnauthorized: false and InsecureSkipVerify: true.
A draft card is not vetted. Read it for ideas, but do not use it for a verdict.
fn-atomic-related-updates
blockdraftcorrectness
Applies when: One operation writes to two or more places that must agree (rows, tables, stores, files).
Rule. Make related writes succeed or fail together: one transaction, or an ordered sequence with a defined undo.
Why. If step two fails after step one, the system holds a half-applied change that no later code expects, such as an order without its items.
FAIL
db.insert(order)
db.insert_many(items) # raises: the order exists with no items
PASS
with db.transaction():
db.insert(order)
db.insert_many(items)
Counterpoint. "The writes go to different systems; no transaction spans them." Then order the steps so a partial failure is harmless and retryable, or use an outbox or saga (svc-saga-steps). Do not leave it to chance. fn-no-check-then-act-across-await covers check-then-write races.
Counterpoint. "A keyword flag is fine and shorter." A named keyword-only flag is acceptable when it is truly binary and will stay so. The FAIL is the positional literal, which is why the severity is discuss.
Tool gate: ruff FBT
fn-comments-survive-context
should-fixdraftmaintainability
Applies when: A comment or TODO refers to a ticket, a PR, a sprint or the present moment.
Rule. Write comments that remain true without outside context: no "see the PR", "currently", "not yet", ticket ids or codenames.
Why. The PR vanishes from memory and the "currently" never gets updated. A comment whose context has expired is a lie by the next release.
FAIL
# TODO(JIRA-4412): handle the legacy case for now
PASS
# TODO: drop this branch once every client sends v2 payloads
Counterpoint. "Ticket links help traceability." Traceability belongs in the commit message and the PR, which are tied to the change. A removal condition in the TODO is what a later reader can act on.
Counterpoint. "Wrapper types add ceremony." A newtype costs one line and no runtime. Where the types differ only by argument position and the function has two arguments with obvious names, keyword-only parameters can be enough.
Tool gate: Type checker with a newtype or branded-type pattern.
Applies when: A function returns a list, set or map.
Rule. Return an empty collection for "nothing found"; return None only when absent differs from empty.
Why. Every caller must remember the None check, and the one who forgets crashes on iteration, in production, on the rare empty case.
FAIL
def find_orders(user) -> list[Order] | None:
return rows or None
PASS
def find_orders(user) -> list[Order]:
return rows
Counterpoint. "None tells me the query failed." Then raise, or return a distinct result type. Using None for both "failed" and "empty" is the bug.
Tool gate: type checker
fn-english-identifiers
discussdraftmaintainability
Applies when: The project has a written language policy, or its code may be read by people outside one language group.
Rule. Write identifiers and comments in English, unless the project policy names another language.
Why. Mixed languages split search, break naming conventions, and exclude readers. The tools, libraries, and error messages the code touches are already in English.
FAIL
def calcular_precio_total(carrito): ... # next to total_price() and cart.items
PASS
def calculate_total_price(cart): ...
Counterpoint. "Domain terms have no English word." Keep the local term, once, in a glossary, and comment it (mod-one-name-per-concept). Python itself allows non-ASCII identifiers so that teams can name things in their own language (PEP 3131). If the code will never leave one language group, a team may choose that language. Write the choice down so the check can pass.
Tool gate: Spell-check or non-ASCII identifier lint.
@dataclass(frozen=True)
class Summary:
id: int
total: Decimal
Counterpoint. "A dict is easier to serialise and extend." Records serialise as well (asdict, model dump). Extension is exactly what the type should make deliberate.
Tool gate: mypy --strict (partial)
fn-name-intermediate-conditions
discussdraftmaintainability
Applies when: An if or while joins three or more terms in one expression.
Rule. Name each compound condition by assigning its sub-conditions to booleans that say what they mean.
Why. Dense conditions force the reader to simulate them. A name states the intent and gives a place to put the next clause.
FAIL
if u.active and not u.banned and (u.plan == "pro" or u.trial_days > 0) and not u.locked:
PASS
can_login = u.active and not u.banned and not u.locked
has_access = u.plan == Plan.PRO or u.trial_days > 0
if can_login and has_access:
Counterpoint. "More variables, more state to track." These are immutable one-use names, and they replace a comment you would otherwise write. If the names turn out vague (flag1), the condition probably mixes two concepts. Fix that instead.
@dataclass(frozen=True)
class ParsedError:
code: str
message: str
Counterpoint. "A pair is obviously (a, b)." That holds only when both elements are the same kind (min and max of one quantity, a coordinate). When two strings play different roles, the position is the only documentation, and it is wrong the day someone adds a third field.
fn-no-check-then-act-across-await
blockdraftcorrectness
Applies when: Code reads shared state, yields (await, I/O, a lock release), then writes based on the earlier read.
Rule. Make check-and-write one atomic operation where the state lives, and never split it across a suspension point.
Why. Another task runs at every yield. Two withdrawals both read 100, both pass the check, and the balance goes negative.
# UPDATE accounts SET balance = balance - :amt WHERE id = :id AND balance >= :amt
if not await repo.debit_if_sufficient(acct, amount):
raise InsufficientFunds(acct)
Counterpoint. "A lock fixes it." A process-local lock covers one process. With two workers or two hosts, you need a transaction, an atomic statement, an idempotency key, or optimistic concurrency. Name the other readers and writers of that state.
fn-no-eval-untrusted
blockdraftsecurity
Applies when: Code runs eval, exec, new Function, an expression language, or compiles a template, and some of the text comes from a request, a file, a database row, or model output.
Rule. Never pass untrusted text to eval, exec, new Function, an expression language, or a template compiler. Pass it to a fixed template or a parser as data.
Why. Each of these turns a string into code that runs with the server's rights, so one field becomes remote code execution. A template engine counts: render_template_string(request.args["msg"]) lets {{ config }} or worse run on the server. Parse the data format instead (json.loads, ast.literal_eval), and keep templates in files the author wrote, with user values passed as variables.
FAIL
total = eval(request.args["expr"])
html = render_template_string("Hello " + request.args["name"])
PASS
data = json.loads(request.get_data())
html = render_template("hello.html", name=request.args["name"])
Counterpoint. "Users need formulas, so we need eval." Write or adopt a small expression parser that knows only numbers and the operators you allow. A sandbox around eval loses to the next escape someone publishes.
Tool gate: Bandit B307 (eval) and B102 (exec_used); CodeQL py/code-injection; ESLint no-eval and no-implied-eval.
Applies when: Code reads or writes an attribute or field by a dynamic key when the name is known at write time.
Rule. Use plain field access, and reserve dynamic access for genuinely dynamic names.
Why. Dynamic access defeats rename tools, type checking, and grep. A rename misses it, and it fails at runtime.
FAIL
value = getattr(order, "total")
PASS
value = order.total
Counterpoint. "I loop over field names." That is fine when the set of names is the data (a mapping from column names, a serializer). If the names are fixed, unroll the loop.
Tool gate: ruff B009 / B010
fn-no-magic-values
should-fixdraftmaintainability
Applies when: A literal string or number carries meaning the reader must guess (status codes, mode names, limits).
Rule. Give every meaningful literal a name: an enum member or a constant.
Why. Literals drift. "canceled" in one file and "cancelled" in another is a silent bug, and nobody can grep for what 3 means.
FAIL
if resp.status == 404 or retries > 3:
mode = "fast"
PASS
if resp.status == HTTPStatus.NOT_FOUND or retries > MAX_RETRIES:
mode = Mode.FAST
Counterpoint. "A constant used once is noise." True for self-evident values (0, 1, "", an index into a pair). The rule is for literals whose meaning is not visible at the use site, or that appear twice. The failure is two spellings of one concept, not the literal itself.
Applies when: A method's whole body calls a collaborator's method of the same name with the same arguments.
Rule. Delete a method that only forwards, and keep wrappers that validate, adapt, or narrow.
Why. A pass-through adds an interface to learn and a hop to step over, and gives no abstraction. Changing the signature now means editing two places.
FAIL
class UserService:
def get(self, id): return self.repo.get(id)
PASS
# callers use repo.get(id) directly,
# or the service adds something: check access, map to a domain error
Counterpoint. "The service layer should expose everything for uniformity." Uniformity does not make an empty layer useful. If a layer boundary is the goal, say what the layer adds. The Clean Code side of the Ousterhout debate favours small forwarding methods more readily, so treat this as a design call, not a law.
Applies when: JavaScript or TypeScript code merges, clones, or assigns properties whose key names come from JSON, a query string, or other untrusted input.
Rule. Never write an untrusted key into a plain object. Use a Map, an object from Object.create(null), or an allowlist of keys, and block __proto__, constructor, and prototype in any deep merge.
Why.obj[key] = value with key set to __proto__ writes to Object.prototype, so every object in the process gains that property. An attacker who sets isAdmin or a template option there can skip checks or reach code execution. The bug sits in generic helpers such as deep merge and path setters, so one bad helper exposes every caller.
FAIL
function merge(dst, src) {
for (const k in src) {
if (typeof src[k] === "object") merge(dst[k] ??= {}, src[k]);
else dst[k] = src[k];
}
}
merge(settings, JSON.parse(body)); // body: {"__proto__": {"isAdmin": true}}
PASS
const ALLOWED = new Set(["theme", "lang"]);
const prefs = new Map();
for (const [k, v] of Object.entries(JSON.parse(body))) {
if (ALLOWED.has(k)) prefs.set(k, v);
}
Counterpoint. "Just freeze Object.prototype at startup." That helps, and the sources list it, but it can break libraries that patch prototypes, and it leaves constructor.prototype paths in class instances open. Fix the merge, and add the freeze on top if your dependencies allow it.
Tool gate: CodeQL js/prototype-pollution-utility and js/prototype-polluting-assignment.
Applies when: A function returns a status, kind or outcome, and callers branch on it.
Rule. Return an enum or a tagged type, never a bare string that the caller must compare.
Why. Callers compare against strings the compiler cannot check. A typo in the producer or the consumer compiles, runs, and takes the wrong branch.
FAIL
def check(order) -> str:
return "missing_items" if not order.lines else "ok"
PASS
class OrderCheck(Enum):
OK = "ok"
MISSING_ITEMS = "missing_items"
def check(order) -> OrderCheck: ...
Counterpoint. "Strings serialise for free." The enum serialises too (by value), and the wire format is unchanged. You gain a closed set that the type checker can exhaust.
Tool gate: type checker with exhaustive match
fn-propagate-dont-swallow
blockdraftcorrectness
Applies when: A handler catches an error and either drops it or raises a different one.
Rule. Chain the original cause when you translate an exception.
Why. Dropping the cause removes the only trace of why the call failed. A translated error without the original traceback sends the on-call person to the wrong place. Never swallow an error silently.
FAIL
try: gateway.charge(...)
except GatewayError:
raise PaymentDeclined(order.id) # cause lost
PASS
try: gateway.charge(...)
except GatewayError as err:
raise PaymentDeclined(order.id) from err
Counterpoint. "Chaining leaks internals to the caller." Chaining stays in logs and tracebacks. What the client sees is a separate mapping (see sys-secrets-and-errors).
Tool gate: ruff B904 (Python)
fn-redirect-target-allowlist
should-fixdraftsecurity
Applies when: A handler redirects to a URL taken from a query parameter, form field, header, or stored value, such as ?next= after login.
Rule. Redirect only to a local path or to a host on a server-side allowlist, never to a URL taken from the request as is.
Why. A link such as https://bank.example/login?next=https://evil.example carries your domain, so users and mail filters trust it, and after login the user lands on a phishing page. When page script sets location from the same value, a javascript: URL runs as script. Parse the URL and compare its scheme and host. A startswith("/") check alone passes //evil.example, and browsers read /\evil.example the same way. Better still, accept a short name and map it to the URL on the server.
FAIL
return redirect(request.args["next"])
PASS
target = urlparse(request.args.get("next", "/"))
if target.scheme or target.netloc or not target.path.startswith("/") or "\\" in target.path:
target = urlparse("/")
return redirect(target.path)
Counterpoint. "The redirect only matters after a successful login." That is when it hurts most: the user has just typed a password and trusts the next page. A phishing copy of the login form on that page collects the password a second time.
Tool gate: CodeQL py/url-redirection; ASP.NET Core LocalRedirect or Url.IsLocalUrl.
Applies when: Code starts a task, thread, goroutine, or subprocess, or opens a handle (file, socket, connection).
Rule. Give every task and handle an owner that waits for it or closes it, on the error path too.
Why. A task started and forgotten outlives its caller. Its errors vanish, nobody cancels it, and its resources are never released, which shows up later as a leak or a stuck shutdown.
FAIL
asyncio.create_task(send_metrics()) # nobody awaits it, nobody cancels it
f = open(path); data = f.read() # leaked if read() raises
PASS
async with asyncio.TaskGroup() as tg: # children finish inside this scope
tg.create_task(send_metrics())
with open(path) as f:
data = f.read()
Counterpoint. "Some work is fire-and-forget by design." Then a long-lived owner (a supervisor or worker pool) holds it, logs its failures, and stops it at shutdown. py-task-reference covers Python task references.
Applies when: Code catches exceptions in business logic.
Rule. Catch only the specific exception you can handle in business code.
Why. A blanket catch treats a mistyped attribute the same as a network blip. Real bugs get swallowed, logged as a warning, and retried. py-ruff-gate enforces this.
FAIL
try:
charge(order)
except Exception:
return None
PASS
try:
charge(order)
except CardDeclined as err:
raise PaymentDeclined(order.id) from err
Counterpoint. "A worker loop must not die on one bad message." That is the one legitimate blanket catch: once, at the process boundary, with the traceback logged, playing the role that a web framework's error middleware plays for routes. Everywhere else, catch specific exceptions. The other exception is a security boundary: catch broadly there, log redacted, and deny (fail closed). See sys-secrets-and-errors.
Applies when: A test asserts on a computed value or asserts that something was not called.
Rule. Take a test's expected value from the requirement, not from the code under test, and pair every "not called" assertion with a case where the call happens.
Why. An expected value computed by the code under test passes when the code is wrong. A "not called" assertion passes when the spy is wired to the wrong target, so it proves nothing until a case shows it can see the call.
FAIL
assert total(items) == sum(i.price for i in items) # re-implements the code
mailer.send.assert_not_called() # no case where it is called
Counterpoint. "Recomputing keeps the test short." It also repeats the bug. Use a literal from the spec or a worked example. See chg-agent-mock-asserting-tests for tests that only check their own mocks.
Applies when: A type or function models a value that is one of several variants or states.
Rule. Model variants as a tagged union, and make every switch over it fail the build when a variant is unhandled.
Why. A bag of optional fields or booleans that must stay in sync allows states that mean nothing, and each reader has to know which combinations are legal. A new variant then slips past every switch that lacks an exhaustiveness check.
FAIL
type Job = { running: boolean; failed: boolean; error?: string; result?: Result }
PASS
type Job =
| { kind: "running" }
| { kind: "failed"; error: string }
| { kind: "done"; result: Result }
default: { const _: never = job; throw new Error("unhandled") }
Counterpoint. "Two booleans are simpler." They are until a third state appears or the booleans disagree. For a true on/off flag with no related fields, a boolean is fine.
Tool gate: A type checker with exhaustiveness checking (never in TypeScript, assert_never in Python).
Applies when: Code parses XML, SVG, SOAP, SAML, or an office document that a user or another system can supply.
Rule. Parse untrusted XML with DTDs, external entities, and network access turned off, or with a hardened wrapper such as defusedxml.
Why. A DTD can declare an entity whose value is file:///etc/passwd or an internal URL, and a parser that resolves it pastes that content into the document or makes the request for the attacker. Nested internal entities can also expand a small file into gigabytes. Each parser has its own switches, so name them in the code instead of trusting the library default.
import defusedxml.ElementTree as ET
doc = ET.fromstring(request.get_data()) # rejects DTD entities by default
Counterpoint. "Recent Python XML modules already refuse external entities." Some do, and CodeQL says so. The FAIL above opts back in, Java and .NET parsers have their own defaults, and a later change to the parser config reopens the hole. An explicit hardened parser makes the setting visible in review.
Tool gate: CodeQL py/xxe; Bandit B313 to B319 (stdlib XML parse calls) and B405 to B410 (XML imports).