Skip to content

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 not null/undefined/false (a 0 count, 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 the jsonArrayFrom / jsonObjectFrom / jsonBuildObject forks in ~/utils/kysely.server instead; 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 pass schema={...} alongside _action so the literal is type checked against the route's schema.
  • 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>.
  • 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.
  • 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.
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 defineAction from ~/form/define-action.server. See docs/site/src/content/docs/dev/forms.mdx.
  • Do not read the request body directly. Declare a body schema in defineAction. 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.ts definition and read them with useSearchParamsTyped. 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.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>.
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 with usePersistedState / 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")
}

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.ts or <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")
}
}