From edbf55239edf7587fdfcde5f4eca3741882e2b71 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:50:08 +0300 Subject: [PATCH] Linter --- AGENTS.md | 3 +-- app/features/auth/core/routes.server.ts | 4 ++++ .../map-list-generator-search-params.test.ts | 11 +---------- app/features/scrims/scrims-utils.ts | 1 + .../tier-list-maker-search-params.test.ts | 15 --------------- ...tration.$tid.captain-label.browser.test.tsx | 1 + app/root.tsx | 2 ++ app/utils/remix.server.test.ts | 2 ++ biome-plugins/no-raw-search-params.grit | 18 ++++++++++++++++++ biome.json | 4 ++++ docs/dev/search-params.md | 8 +++++++- 11 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 biome-plugins/no-raw-search-params.grit diff --git a/AGENTS.md b/AGENTS.md index ab74493c8..29f4e47f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,7 @@ ## Search params - all URL search param handling goes through `app/modules/search-params/`, see [search-params.md](./docs/dev/search-params.md) for the conventions -// xxx: biome plugin to check for that? -- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `-search-params.ts` definition (every param has a default, decode never fails) +- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `-search-params.ts` definition (every param has a default, decode never fails). Enforced by the `no-raw-search-params` Biome plugin - every definition gets a round-trip test via `assertRoundTrips` ## Styling diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts index c548cfbe9..b52973b9f 100644 --- a/app/features/auth/core/routes.server.ts +++ b/app/features/auth/core/routes.server.ts @@ -23,6 +23,7 @@ import { authSessionStorage } from "./session.server"; import { getUser } from "./user.server"; export const callbackLoader: LoaderFunction = async ({ request, url }) => { + // biome-ignore lint/plugin: OAuth callback param, its name and values defined by the provider if (url.searchParams.get("error") === "access_denied") { // The user denied the authentication request // https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/ @@ -78,6 +79,7 @@ export const impersonateAction: ActionFunction = async ({ request, url }) => { } if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) { + // biome-ignore lint/plugin: a missing or malformed `id` must 400, not fall back to a default const targetId = Number(url.searchParams.get("id")); if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) { throw new Response("Forbidden", { status: 403 }); @@ -93,9 +95,11 @@ export const impersonateAction: ActionFunction = async ({ request, url }) => { const realUserId = session.get(SESSION_KEY); + // biome-ignore-start lint/plugin: a missing or malformed `id` must 400, not fall back to a default const rawId = url.searchParams.get("id"); const userId = Number(url.searchParams.get("id")); + // biome-ignore-end lint/plugin: a missing or malformed `id` must 400, not fall back to a default if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 }); logger.info( diff --git a/app/features/map-list-generator/map-list-generator-search-params.test.ts b/app/features/map-list-generator/map-list-generator-search-params.test.ts index f206f96c4..b449c3484 100644 --- a/app/features/map-list-generator/map-list-generator-search-params.test.ts +++ b/app/features/map-list-generator/map-list-generator-search-params.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it } from "vitest"; -import * as SearchParams from "~/modules/search-params/search-params"; +import { describe, it } from "vitest"; import { assertDecodesToDefault, assertRoundTrips, @@ -19,14 +18,6 @@ describe("mapListGeneratorSearchParams", () => { }); }); - it("decodes the legacy bare readonly form", () => { - expect( - SearchParams.decodeParam(mapListGeneratorSearchParams.shape.readonly, [ - "", - ]), - ).toBe(true); - }); - it("decodes garbage to defaults", () => { assertDecodesToDefault(mapListGeneratorSearchParams, "eventId", [ ["abc"], diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 6ad99fa7b..381dd6f33 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -147,6 +147,7 @@ function extractSerializedPool(input: string): string | null { if (trimmed.includes("://")) { try { const url = new URL(trimmed); + // biome-ignore lint/plugin: URL pasted by the user, not one this app routed to return url.searchParams.get("pool"); } catch { return null; diff --git a/app/features/tier-list-maker/tier-list-maker-search-params.test.ts b/app/features/tier-list-maker/tier-list-maker-search-params.test.ts index 021ea6506..59c68ba7f 100644 --- a/app/features/tier-list-maker/tier-list-maker-search-params.test.ts +++ b/app/features/tier-list-maker/tier-list-maker-search-params.test.ts @@ -4,7 +4,6 @@ import { assertDecodesToDefault, assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; -import { compressToBase64 } from "~/utils/compression"; import { DEFAULT_TIERS } from "./tier-list-maker-constants"; import type { TierListState } from "./tier-list-maker-schemas"; import { tierListMakerSearchParams } from "./tier-list-maker-search-params"; @@ -48,20 +47,6 @@ describe("tierListMakerSearchParams", () => { expect(encoded[0]).toMatch(/^lz~/); }); - it("decodes the legacy signature-less base64 state format", () => { - const legacy = compressToBase64( - JSON.stringify({ - tiers: FILLED_STATE.tiers, - tierItems: Array.from(FILLED_STATE.tierItems.entries()), - }), - { urlSafe: true }, - ); - - expect( - SearchParams.decodeParam(tierListMakerSearchParams.shape.state, [legacy]), - ).toEqual(FILLED_STATE); - }); - it("decodes the legacy JSON modes format", () => { expect( SearchParams.decodeParam(tierListMakerSearchParams.shape.modes, [ diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx index d24db8adc..14e38747c 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx @@ -54,6 +54,7 @@ function renderPage() { { path: "/search", loader: ({ request }: LoaderFunctionArgs) => { + // biome-ignore lint/plugin: stub loader standing in for the real search route, reading the request the component built const query = new URL(request.url).searchParams.get("q") ?? ""; return { query, diff --git a/app/root.tsx b/app/root.tsx index b8f2ab0bc..39d977dd9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -98,6 +98,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => { const json = args.json as Record | undefined; if (json?.revalidateRoot === true) return true; + // biome-ignore lint/plugin: presence check only, before any route's definition has parsed the URL if (args.nextUrl.searchParams.has("lng")) return true; return false; @@ -270,6 +271,7 @@ function useExternalAwareHref(href: string) { } function useTriggerToasts() { + // biome-ignore lint/plugin: app-wide toast params written by server redirects, belonging to no one feature const [searchParams] = useSearchParams(); const navigate = useNavigate(); diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts index a36b4dd51..e292d5e3b 100644 --- a/app/utils/remix.server.test.ts +++ b/app/utils/remix.server.test.ts @@ -81,9 +81,11 @@ describe("paginate()", () => { expect(location).not.toBeNull(); const locationUrl = new URL(location!, "https://sendou.ink"); expect(locationUrl.pathname).toBe("/vods"); + // biome-ignore-start lint/plugin: asserting on the raw redirect URL is the point of the test expect(locationUrl.searchParams.get("page")).toBe("3"); expect(locationUrl.searchParams.get("type")).toBe("TOURNAMENT"); expect(locationUrl.searchParams.get("mode")).toBe("SZ"); + // biome-ignore-end lint/plugin: asserting on the raw redirect URL is the point of the test }); it("stays on page 1 when there are no results", () => { diff --git a/biome-plugins/no-raw-search-params.grit b/biome-plugins/no-raw-search-params.grit new file mode 100644 index 000000000..99b9d17a1 --- /dev/null +++ b/biome-plugins/no-raw-search-params.grit @@ -0,0 +1,18 @@ +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/dev/search-params.md. +or { + `useSearchParams()` as $hook where { + register_diagnostic(span=$hook, message="Do not use raw `useSearchParams`. Declare the params in a `-search-params.ts` definition and read them with `useSearchParamsTyped`. See docs/dev/search-params.md.", 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 `-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: `.", severity="error") + } +} diff --git a/biome.json b/biome.json index 21370d331..97b78e1f4 100644 --- a/biome.json +++ b/biome.json @@ -73,6 +73,10 @@ } }, "overrides": [ + { + "includes": ["app/**", "!app/modules/search-params/**"], + "plugins": ["./biome-plugins/no-raw-search-params.grit"] + }, { "includes": ["**/*.test.ts", "**/*.test.tsx"], "plugins": ["./biome-plugins/no-raw-db-writes-in-tests.grit"] diff --git a/docs/dev/search-params.md b/docs/dev/search-params.md index 3976905a6..219783054 100644 --- a/docs/dev/search-params.md +++ b/docs/dev/search-params.md @@ -111,4 +111,10 @@ export const shouldRevalidate = buildsSearchParams.shouldRevalidate; // revalidates only when a loader:true param's decoded canonical value changed ``` -Submissions, revalidator calls, pathname changes and unknown-param changes defer to the router default. \ No newline at end of file +Submissions, revalidator calls, pathname changes and unknown-param changes defer to the router default. + +## Enforcement + +The `no-raw-search-params` Biome plugin fails the lint on `useSearchParams()` and on `…searchParams.get/getAll/has(…)` anywhere in `app/` outside this module. + +The escape hatch is `// biome-ignore lint/plugin: `, for the cases the convention genuinely does not cover: URLs this app did not route to (an OAuth provider's callback params, a URL pasted by a user), and reads where the total-decoding guarantee is wrong — a param whose absence must fail the request rather than resolve to a default. \ No newline at end of file