Lint rules
The repo's own lint rules: GritQL plugins in biome-plugins/, enabled per path in biome.json. They run with pnpm run biome:fix. Silence one where it's wrong with // biome-ignore lint/plugin: <why>.
no-and-conditional-rendering
cond && <X /> renders the left value when it is falsy but not null / undefined / false: a 0 count or an empty string ends up in the DOM as text. The ternary form has no such edge, so it is the repo-wide convention (AGENTS.md, "React"). Matches both cond && <jsx/> anywhere and any && standing directly as a JSX child, which covers helpers like {icon && React.cloneElement(icon, {...})}.
Applies to app/**
Reports
- Do not use
&&for conditional rendering: a falsy left side that is notnull/undefined/false(a0count, an empty string,NaN) renders itself. Use a ternary instead:cond ? <X /> : null.
Source (biome-plugins/no-and-conditional-rendering.grit)
language js
// `cond && <X />` renders the left value when it is falsy but not `null` /// `undefined` / `false`: a `0` count or an empty string ends up in the DOM as// text. The ternary form has no such edge, so it is the repo-wide convention// (AGENTS.md, "React"). Matches both `cond && <jsx/>` anywhere and any `&&`// standing directly as a JSX child, which covers helpers like// `{icon && React.cloneElement(icon, {...})}`.`$cond && $rendered` as $expression where { or { $rendered <: or { JsxTagExpression(), JsParenthesizedExpression(expression=JsxTagExpression()) }, $expression <: within JsxExpressionChild(expression=$expression) }, register_diagnostic(span=$expression, message="Do not use `&&` for conditional rendering: a falsy left side that is not `null`/`undefined`/`false` (a `0` count, an empty string, `NaN`) renders itself. Use a ternary instead: `cond ? <X /> : null`.", severity="error")}no-kysely-sqlite-helpers
The kysely sqlite json helpers embed nested JSON documents as escaped strings (SQLite's JSON subtype does not survive a subquery boundary) and rely on a result-walking parse plugin this codebase no longer has. The forks in app/utils/kysely.server.ts re-tag nested JSON values with json() so the dialect's single-pass per-column parsing sees fully nested documents.
Applies to **, !app/utils/kysely.server.ts
Reports
- Do not import from
kysely/helpers/sqlite. Use thejsonArrayFrom/jsonObjectFrom/jsonBuildObjectforks in~/utils/kysely.serverinstead; the kysely versions return nested JSON documents as strings.
Source (biome-plugins/no-kysely-sqlite-helpers.grit)
language js
// The kysely sqlite json helpers embed nested JSON documents as escaped strings// (SQLite's JSON subtype does not survive a subquery boundary) and rely on a// result-walking parse plugin this codebase no longer has. The forks in// `app/utils/kysely.server.ts` re-tag nested JSON values with `json()` so the// dialect's single-pass per-column parsing sees fully nested documents.`"kysely/helpers/sqlite"` as $source where { register_diagnostic(span=$source, message="Do not import from `kysely/helpers/sqlite`. Use the `jsonArrayFrom` / `jsonObjectFrom` / `jsonBuildObject` forks in `~/utils/kysely.server` instead; the kysely versions return nested JSON documents as strings.", severity="error")}no-raw-action-forms
Fixed-field mutations (an _action plus hidden inputs) go through <ActionButton> (app/components/ActionButton.tsx). Real multi-input forms keep their own form but pass schema alongside _action to SubmitButton so the action literal is type checked against the route's action schema.
Applies to app/**, !app/hooks/useActionSubmit.ts
Reports
- Untyped
_action. For a fixed-field mutation use<ActionButton schema={...} action="..." fields={{...}}>; for a submit button of a real multi-input form passschema={...}alongside_actionso the literal is type checked against the route's schema. - Do not submit
_actionvia a hidden input or button name. Use<ActionButton schema={...} action="..." fields={{...}}>, or aSubmitButtonwithschemaand_actioninside a real multi-input form. If neither fits, suppress with// biome-ignore lint/plugin: <reason>. - Do not submit
_actionpayloads with a raw fetcher.submit. Use theuseActionSubmithook so the action and fields are type checked against the route's schema. Suppress with// biome-ignore lint/plugin: <reason>if this cannot go through the hook. - Do not build
_actionFormData by hand. Use theuseActionSubmithook so the action and fields are type checked against the route's schema. Suppress with// biome-ignore lint/plugin: <reason>if this payload includes files.
Source (biome-plugins/no-raw-action-forms.grit)
language js
// Fixed-field mutations (an `_action` plus hidden inputs) go through// `<ActionButton>` (app/components/ActionButton.tsx). Real multi-input forms// keep their own form but pass `schema` alongside `_action` to `SubmitButton`// so the action literal is type checked against the route's action schema.or { JsxAttribute(name=`_action`) as $attr where { $attr <: not within or { JsxOpeningElement(attributes=contains JsxAttribute(name=`schema`)), JsxSelfClosingElement(attributes=contains JsxAttribute(name=`schema`)) }, register_diagnostic(span=$attr, message="Untyped `_action`. For a fixed-field mutation use `<ActionButton schema={...} action=\"...\" fields={{...}}>`; for a submit button of a real multi-input form pass `schema={...}` alongside `_action` so the literal is type checked against the route's schema.", severity="error") }, `name="_action"` as $attr where { register_diagnostic(span=$attr, message="Do not submit `_action` via a hidden input or button name. Use `<ActionButton schema={...} action=\"...\" fields={{...}}>`, or a `SubmitButton` with `schema` and `_action` inside a real multi-input form. If neither fits, suppress with `// biome-ignore lint/plugin: <reason>`.", severity="error") }, `$fetcher.submit($payload, $opts)` as $call where { $payload <: contains `_action: $_`, register_diagnostic(span=$call, message="Do not submit `_action` payloads with a raw fetcher.submit. Use the `useActionSubmit` hook so the action and fields are type checked against the route's schema. Suppress with `// biome-ignore lint/plugin: <reason>` if this cannot go through the hook.", severity="error") }, `$formData.append("_action", $value)` as $append where { register_diagnostic(span=$append, message="Do not build `_action` FormData by hand. Use the `useActionSubmit` hook so the action and fields are type checked against the route's schema. Suppress with `// biome-ignore lint/plugin: <reason>` if this payload includes files.", severity="error") }}no-raw-actions
Route actions parse their inputs through defineAction (app/form/define-action.server.ts), so params, body validation and its failure handling are declared the same way everywhere.
Applies to app/**, !app/form/parse.server.ts, !app/form/define-action.server.ts
Reports
- Define route actions with
defineActionfrom~/form/define-action.server. See docs/site/src/content/docs/dev/forms.mdx. - Do not read the request body directly. Declare a
bodyschema indefineAction. See docs/site/src/content/docs/dev/forms.mdx.
Source (biome-plugins/no-raw-actions.grit)
language js
// Route actions parse their inputs through `defineAction` (app/form/define-action.server.ts),// so params, body validation and its failure handling are declared the same way everywhere.or { or { `export const $name = $value`, `export const $name: $_ = $value` } as $export where { $name <: r"^(?:action|[a-z][A-Za-z0-9]*Action)\z", $value <: not `defineAction($...)`, register_diagnostic(span=$export, message="Define route actions with `defineAction` from `~/form/define-action.server`. See docs/site/src/content/docs/dev/forms.mdx.", severity="error") }, or { `export async function $name($...) { $... }`, `export function $name($...) { $... }` } as $export where { $name <: r"^(?:action|[a-z][A-Za-z0-9]*Action)\z", register_diagnostic(span=$export, message="Define route actions with `defineAction` from `~/form/define-action.server`. See docs/site/src/content/docs/dev/forms.mdx.", severity="error") }, `$request.$method()` as $read where { $request <: or { `request`, `$_.request` }, $method <: or { `formData`, `json`, `text` }, register_diagnostic(span=$read, message="Do not read the request body directly. Declare a `body` schema in `defineAction`. See docs/site/src/content/docs/dev/forms.mdx.", severity="error") }}no-raw-db-writes-in-dev-seed
The dev dataset composes factories only, so that seeding exercises the same write paths the app does. The sanctioned exceptions (backdate, pinUserId, a column no production write reaches) live in app/db/seed/core and inside factory options — never in dev/.
Applies to app/db/seed/dev/**, app/db/seed/index.ts
Reports
- The dev seed must not write to the database directly. Compose factories from
app/db/seed/factories; if none fits, add one wrapping the repository write.
Source (biome-plugins/no-raw-db-writes-in-dev-seed.grit)
language js
// The dev dataset composes factories only, so that seeding exercises the same// write paths the app does. The sanctioned exceptions (`backdate`, `pinUserId`,// a column no production write reaches) live in `app/db/seed/core` and inside// factory options — never in `dev/`.`$connection.$method($_)` as $write where { $connection <: or { `db`, `trx` }, $method <: or { `insertInto`, `updateTable`, `deleteFrom`, `replaceInto` }, register_diagnostic(span=$write, message="The dev seed must not write to the database directly. Compose factories from `app/db/seed/factories`; if none fits, add one wrapping the repository write.", severity="error")}no-raw-db-writes-in-tests
Tests arrange their state through app/db/seed/factories, so that seeding exercises the same write paths the app does. A raw write is only allowed when no production write reaches the state at all, and then it has to say so: // biome-ignore lint/plugin: <why no production write can do this>
Applies to **/*.test.ts, **/*.test.tsx
Reports
- Tests must not write to the database directly. Use a factory from
app/db/seed/factories, or a repository write function if the test is about that write. If no production write reaches this state, keep the raw write and suppress with// biome-ignore lint/plugin: <reason>.
Source (biome-plugins/no-raw-db-writes-in-tests.grit)
language js
// Tests arrange their state through `app/db/seed/factories`, so that seeding// exercises the same write paths the app does. A raw write is only allowed when// no production write reaches the state at all, and then it has to say so:// `// biome-ignore lint/plugin: <why no production write can do this>``$connection.$method($_)` as $write where { // `trx` too, so that a write inside a test's own `db.transaction()` block is // caught the same way the ones on the singleton are $connection <: or { `db`, `trx` }, $method <: or { `insertInto`, `updateTable`, `deleteFrom`, `replaceInto` }, register_diagnostic(span=$write, message="Tests must not write to the database directly. Use a factory from `app/db/seed/factories`, or a repository write function if the test is about that write. If no production write reaches this state, keep the raw write and suppress with `// biome-ignore lint/plugin: <reason>`.", severity="error")}no-raw-search-params
All URL search param handling goes through app/modules/search-params, so that a param's codec, default and loader-relevance are declared exactly once. See docs/site/src/content/docs/dev/search-params.mdx.
Applies to app/**, !app/modules/search-params/**
Reports
- Do not use raw
useSearchParams. Declare the params in a<feature>-search-params.tsdefinition and read them withuseSearchParamsTyped. See docs/site/src/content/docs/dev/search-params.mdx. - Do not read search params off a URL directly. Declare them in a
<feature>-search-params.tsdefinition and read them withdefinition.parse(request). If this is not a sendou.ink URL (an outgoing API call, or a URL pasted by a user), suppress with// biome-ignore lint/plugin: <reason>.
Source (biome-plugins/no-raw-search-params.grit)
language js
// All URL search param handling goes through `app/modules/search-params`, so// that a param's codec, default and loader-relevance are declared exactly once.// See docs/site/src/content/docs/dev/search-params.mdx.or { `useSearchParams()` as $hook where { register_diagnostic(span=$hook, message="Do not use raw `useSearchParams`. Declare the params in a `<feature>-search-params.ts` definition and read them with `useSearchParamsTyped`. See docs/site/src/content/docs/dev/search-params.mdx.", severity="error") }, `$_.searchParams.$method($_)` as $read where { $method <: or { `get`, `getAll`, `has` }, register_diagnostic(span=$read, message="Do not read search params off a URL directly. Declare them in a `<feature>-search-params.ts` definition and read them with `definition.parse(request)`. If this is not a sendou.ink URL (an outgoing API call, or a URL pasted by a user), suppress with `// biome-ignore lint/plugin: <reason>`.", severity="error") }}no-raw-web-storage
All localStorage / sessionStorage access goes through app/modules/persisted-state, so a stored value's schema, default and cross-tab sync are declared once.
Applies to app/**, !app/modules/persisted-state/**, !**/*.test.ts, !**/*.test.tsx
Reports
- Do not use web storage directly. Declare the value with
PersistedState.define/defineMap(app/modules/persisted-state) and read it withusePersistedState/PersistedState.read. Suppress with// biome-ignore lint/plugin: <reason>if it can't go through the module.
Source (biome-plugins/no-raw-web-storage.grit)
language js
// All localStorage / sessionStorage access goes through `app/modules/persisted-state`,// so a stored value's schema, default and cross-tab sync are declared once.or { `localStorage` as $storage, `sessionStorage` as $storage} where { register_diagnostic(span=$storage, message="Do not use web storage directly. Declare the value with `PersistedState.define` / `defineMap` (app/modules/persisted-state) and read it with `usePersistedState` / `PersistedState.read`. Suppress with `// biome-ignore lint/plugin: <reason>` if it can't go through the module.", severity="error")}no-relative-links
The patched <Link> (patches/react-router@*.patch) renders an absolute path ("/foo") once and reads the location only when clicked. A relative target ("edit", "?tab=x") resolves against the current location, so every such link re-renders on each navigation. Build targets with the URL helpers (app/utils/urls.ts, <feature>-urls.ts) instead.
Applies to app/**, !app/features/components-showcase/**
Reports
- Relative link target. Use an absolute path from a URL helper (
app/utils/urls.tsor<feature>-urls.ts) so the link does not re-render on every navigation. - Search-param-only link target, it resolves against the current location. Pass the page path as the first argument so the link does not re-render on every navigation. If the link must stay on whichever page is open, suppress with
// biome-ignore lint/plugin: <reason>.
Source (biome-plugins/no-relative-links.grit)
language js
// The patched `<Link>` (patches/react-router@*.patch) renders an absolute path ("/foo") once// and reads the location only when clicked. A relative target ("edit", "?tab=x") resolves// against the current location, so every such link re-renders on each navigation. Build// targets with the URL helpers (`app/utils/urls.ts`, `<feature>-urls.ts`) instead.or { JsxAttribute(name=`to`, initializer=$initializer) as $attr where { $initializer <: r"=\s*\"[^\"]*\"", $initializer <: not or { r"=\s*\"/[^\"]*\"", r"=\s*\"https?:[^\"]*\"", r"=\s*\"mailto:[^\"]*\"" }, register_diagnostic(span=$attr, message="Relative link target. Use an absolute path from a URL helper (`app/utils/urls.ts` or `<feature>-urls.ts`) so the link does not re-render on every navigation.", severity="error") }, `$definition.href("", $_)` as $call where { register_diagnostic(span=$call, message="Search-param-only link target, it resolves against the current location. Pass the page path as the first argument so the link does not re-render on every navigation. If the link must stay on whichever page is open, suppress with `// biome-ignore lint/plugin: <reason>`.", severity="error") }}