From c94c30e4591350509b4883a3ec3f7af08b08bfc0 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 14:41:30 +0100 Subject: [PATCH] Add oxlint for type-aware linting that Biome cannot cover Install oxlint with tsgolint for TypeScript type information. Enable rules for unnecessary type assertions, deprecated API usage, preferring replaceAll over replace with global regex, and String.raw for escaped backslashes. Fix all violations: remove redundant as-casts, replace deprecated FormEvent with SubmitEvent, convert replace(/g) to replaceAll, and use String.raw in escapeRegExp. Add oxlint to the pnpm check gate alongside Biome. Co-Authored-By: Claude Opus 4.6 --- .oxlintrc.json | 26 ++ CLAUDE.md | 8 +- apps/web/src/App.tsx | 6 +- .../src/adapters/__tests__/strip-tags.test.ts | 4 +- apps/web/src/adapters/bestiary-adapter.ts | 4 +- apps/web/src/adapters/strip-tags.ts | 41 +-- apps/web/src/components/action-bar.tsx | 17 +- .../src/components/create-player-modal.tsx | 4 +- apps/web/src/hooks/use-encounter.ts | 4 +- package.json | 5 +- packages/domain/src/auto-number.ts | 2 +- pnpm-lock.yaml | 271 ++++++++++++++++++ 12 files changed, 345 insertions(+), 47 deletions(-) create mode 100644 .oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..879b145 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json", + "plugins": ["typescript", "unicorn", "jest"], + "categories": {}, + "rules": { + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-deprecated": "warn", + "unicorn/prefer-string-replace-all": "error", + "unicorn/prefer-string-raw": "error", + "jest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "expectDomainError"] + } + ] + }, + "ignorePatterns": [ + "dist", + "coverage", + ".claude", + ".specify", + "specs", + ".pnpm-store", + "scripts" + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 770fe05..699b6c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd) +pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd) +pnpm oxlint # Type-aware linting (oxlint — complements Biome) pnpm knip # Unused code detection (Knip) pnpm test # Run all tests (Vitest) pnpm test:watch # Tests in watch mode @@ -58,12 +59,13 @@ docs/agents/ RPI skill artifacts (research reports, plans) - React 19, Vite 6, Tailwind CSS v4 - Lucide React (icons) - `idb` (IndexedDB wrapper for bestiary cache) -- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection) +- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection) - Vitest (testing, v8 coverage), Lefthook (pre-commit hooks) ## Conventions -- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically. +- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically. +- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`. - **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`). - **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. - **Domain events** are plain data objects with a `type` discriminant — no classes. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f1d5717..ef7f90b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -157,8 +157,8 @@ export function App() { (result: SearchResult) => { const slug = result.name .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, ""); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; sidePanel.showCreature(cId); }, @@ -205,7 +205,7 @@ export function App() { if (!globalThis.matchMedia("(min-width: 1024px)").matches) return; const active = encounter.combatants[encounter.activeIndex]; if (!active?.creatureId || !isLoaded) return; - sidePanel.showCreature(active.creatureId as CreatureId); + sidePanel.showCreature(active.creatureId); }, [ encounter.activeIndex, encounter.combatants, diff --git a/apps/web/src/adapters/__tests__/strip-tags.test.ts b/apps/web/src/adapters/__tests__/strip-tags.test.ts index fe88001..101b5ca 100644 --- a/apps/web/src/adapters/__tests__/strip-tags.test.ts +++ b/apps/web/src/adapters/__tests__/strip-tags.test.ts @@ -30,11 +30,11 @@ describe("stripTags", () => { expect(stripTags("{@hit 5}")).toBe("+5"); }); - it("strips {@h} to Hit: ", () => { + it("strips {@h} to Hit:", () => { expect(stripTags("{@h}")).toBe("Hit: "); }); - it("strips {@hom} to Hit or Miss: ", () => { + it("strips {@hom} to Hit or Miss:", () => { expect(stripTags("{@hom}")).toBe("Hit or Miss: "); }); diff --git a/apps/web/src/adapters/bestiary-adapter.ts b/apps/web/src/adapters/bestiary-adapter.ts index ba2a5b8..e2fcfb7 100644 --- a/apps/web/src/adapters/bestiary-adapter.ts +++ b/apps/web/src/adapters/bestiary-adapter.ts @@ -373,8 +373,8 @@ function extractCr(cr: string | { cr: string } | undefined): string { function makeCreatureId(source: string, name: string): CreatureId { const slug = name .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, ""); return creatureId(`${source.toLowerCase()}:${slug}`); } diff --git a/apps/web/src/adapters/strip-tags.ts b/apps/web/src/adapters/strip-tags.ts index 3ab37f9..298e287 100644 --- a/apps/web/src/adapters/strip-tags.ts +++ b/apps/web/src/adapters/strip-tags.ts @@ -25,55 +25,58 @@ export function stripTags(text: string): string { let result = text; // {@h} → "Hit: " - result = result.replace(/\{@h\}/g, "Hit: "); + result = result.replaceAll("{@h}", "Hit: "); // {@hom} → "Hit or Miss: " - result = result.replace(/\{@hom\}/g, "Hit or Miss: "); + result = result.replaceAll("{@hom}", "Hit or Miss: "); // {@actTrigger} → "Trigger:" - result = result.replace(/\{@actTrigger\}/g, "Trigger:"); + result = result.replaceAll("{@actTrigger}", "Trigger:"); // {@actResponse} → "Response:" - result = result.replace(/\{@actResponse\}/g, "Response:"); + result = result.replaceAll("{@actResponse}", "Response:"); // {@actSaveSuccess} → "Success:" - result = result.replace(/\{@actSaveSuccess\}/g, "Success:"); + result = result.replaceAll("{@actSaveSuccess}", "Success:"); // {@actSaveSuccessOrFail} → handled below as parameterized // {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)" - result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)"); - result = result.replace(/\{@recharge\}/g, "(Recharge 6)"); + result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)"); + result = result.replaceAll("{@recharge}", "(Recharge 6)"); // {@dc N} → "DC N" - result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1"); + result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1"); // {@hit N} → "+N" - result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1"); + result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1"); // {@atkr type} → mapped attack roll text - result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => { + result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => { return ATKR_MAP[type.trim()] ?? "Attack Roll:"; }); // {@actSave ability} → "Ability saving throw" - result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => { - const name = ABILITY_MAP[ability.trim().toLowerCase()]; - return name ? `${name} saving throw` : `${ability} saving throw`; - }); + result = result.replaceAll( + /\{@actSave\s+([^}]+)\}/g, + (_, ability: string) => { + const name = ABILITY_MAP[ability.trim().toLowerCase()]; + return name ? `${name} saving throw` : `${ability} saving throw`; + }, + ); // {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:" - result = result.replace( + result = result.replaceAll( /\{@actSaveFail\s+(\d+)\}/g, "Failure by $1 or More:", ); - result = result.replace(/\{@actSaveFail\}/g, "Failure:"); + result = result.replaceAll("{@actSaveFail}", "Failure:"); // {@actSaveSuccessOrFail} → keep as-is label - result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:"); + result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:"); // {@actSaveFailBy N} → "Failure by N or More:" - result = result.replace( + result = result.replaceAll( /\{@actSaveFailBy\s+(\d+)\}/g, "Failure by $1 or More:", ); @@ -81,7 +84,7 @@ export function stripTags(text: string): string { // Generic tags: {@tag Display|Source|...} → Display (first segment before |) // Covers: spell, condition, damage, dice, variantrule, action, skill, // creature, hazard, status, plus any unknown tags - result = result.replace( + result = result.replaceAll( /\{@(\w+)\s+([^}]+)\}/g, (_, tag: string, content: string) => { // For tags with Display|Source format, extract first segment diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 3ec7afb..4617c53 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,4 +1,4 @@ -import type { PlayerCharacter, PlayerIcon } from "@initiative/domain"; +import type { PlayerCharacter } from "@initiative/domain"; import { Check, Eye, @@ -9,12 +9,7 @@ import { Plus, Users, } from "lucide-react"; -import { - type FormEvent, - type RefObject, - useDeferredValue, - useState, -} from "react"; +import { type RefObject, useDeferredValue, useState } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; @@ -103,11 +98,9 @@ function AddModeSuggestions({