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({