Add biome-ignore backpressure script, convert modals to native <dialog>
Adds scripts/check-lint-ignores.mjs with four enforcement mechanisms: ratcheting count cap (12 source / 3 test), banned rule prefixes, required justification, and separate test thresholds. Wired into pnpm check. Converts player-management and create-player-modal from div-based modals to native <dialog> with showModal()/close(), removing 8 biome-ignore comments. Remaining ignores are legitimate (Biome false positives or stopPropagation wrappers with no fitting role). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -527,8 +527,8 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
@@ -587,8 +587,8 @@ export function CombatantRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
<div
|
<div
|
||||||
className={cn(dimmed && "opacity-50")}
|
className={cn(dimmed && "opacity-50")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -598,8 +598,8 @@ export function CombatantRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -25,6 +25,7 @@ export function CreatePlayerModal({
|
|||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: Readonly<CreatePlayerModalProps>) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
@@ -54,15 +55,32 @@ export function CreatePlayerModal({
|
|||||||
}, [open, playerCharacter]);
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
const dialog = dialogRef.current;
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
if (!dialog) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}, [open]);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -86,106 +104,89 @@ export function CreatePlayerModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
ref={dialogRef}
|
||||||
<div
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
<div
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
</h2>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<div className="mb-4 flex items-center justify-between">
|
size="icon"
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
onClick={onClose}
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
className="text-muted-foreground"
|
||||||
</h2>
|
>
|
||||||
<Button
|
<X size={20} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
>
|
<div>
|
||||||
<X size={20} />
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||||
</Button>
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="Character name"
|
||||||
|
aria-label="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<div className="flex gap-3">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ac}
|
||||||
|
onChange={(e) => setAc(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
aria-label="AC"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Name
|
Max HP
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
inputMode="numeric"
|
||||||
onChange={(e) => {
|
value={maxHp}
|
||||||
setName(e.target.value);
|
onChange={(e) => setMaxHp(e.target.value)}
|
||||||
setError("");
|
placeholder="Max HP"
|
||||||
}}
|
aria-label="Max HP"
|
||||||
placeholder="Character name"
|
className="text-center"
|
||||||
aria-label="Name"
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
{!!error && (
|
|
||||||
<p className="mt-1 text-destructive text-sm">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div>
|
||||||
<div className="flex-1">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
Color
|
||||||
AC
|
</span>
|
||||||
</span>
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
<Input
|
</div>
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={ac}
|
|
||||||
onChange={(e) => setAc(e.target.value)}
|
|
||||||
placeholder="AC"
|
|
||||||
aria-label="AC"
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="mb-1 block text-muted-foreground text-sm">
|
|
||||||
Max HP
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={maxHp}
|
|
||||||
onChange={(e) => setMaxHp(e.target.value)}
|
|
||||||
placeholder="Max HP"
|
|
||||||
aria-label="Max HP"
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-muted-foreground text-sm">
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||||
Color
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
</span>
|
</div>
|
||||||
<ColorPalette value={color} onChange={setColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<span className="mb-2 block text-muted-foreground text-sm">
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
Icon
|
Cancel
|
||||||
</span>
|
</Button>
|
||||||
<IconGrid value={icon} onChange={setIcon} />
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
</dialog>
|
||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
@@ -22,102 +22,112 @@ export function PlayerManagement({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
useEffect(() => {
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
if (!open) return;
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
ref={dialogRef}
|
||||||
<div
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
<div
|
Player Characters
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
</h2>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<div className="mb-4 flex items-center justify-between">
|
size="icon"
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
onClick={onClose}
|
||||||
Player Characters
|
className="text-muted-foreground"
|
||||||
</h2>
|
>
|
||||||
<Button
|
<X size={20} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
{characters.length === 0 ? (
|
||||||
>
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
<X size={20} />
|
<p className="text-muted-foreground">No player characters yet</p>
|
||||||
|
<Button onClick={onCreate}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create your first player character
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{characters.length === 0 ? (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
{characters.map((pc) => {
|
||||||
<p className="text-muted-foreground">No player characters yet</p>
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
<Button onClick={onCreate}>
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pc.id}
|
||||||
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
|
>
|
||||||
|
{!!Icon && (
|
||||||
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
|
{pc.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
AC {pc.ac}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
HP {pc.maxHp}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onEdit(pc)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
label="Delete player character"
|
||||||
|
onConfirm={() => onDelete(pc.id)}
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button onClick={onCreate} variant="ghost">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Create your first player character
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
)}
|
||||||
{characters.map((pc) => {
|
</dialog>
|
||||||
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
|
||||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pc.id}
|
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
|
||||||
>
|
|
||||||
{!!Icon && (
|
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 truncate text-foreground text-sm">
|
|
||||||
{pc.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
AC {pc.ac}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
HP {pc.maxHp}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => onEdit(pc)}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</Button>
|
|
||||||
<ConfirmButton
|
|
||||||
icon={<Trash2 size={14} />}
|
|
||||||
label="Delete player character"
|
|
||||||
onConfirm={() => onDelete(pc.id)}
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="mt-2 flex justify-end">
|
|
||||||
<Button onClick={onCreate} variant="ghost">
|
|
||||||
<Plus size={16} />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && tsc --build && vitest run && jscpd"
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
scripts/check-lint-ignores.mjs
Normal file
108
scripts/check-lint-ignores.mjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Backpressure check for biome-ignore comments.
|
||||||
|
*
|
||||||
|
* 1. Ratcheting cap — source and test files have separate max counts.
|
||||||
|
* Lower these numbers as you fix ignores; they can never go up silently.
|
||||||
|
* 2. Banned rules — ignoring certain rule categories is never allowed.
|
||||||
|
* 3. Justification — every ignore must have a non-empty explanation after
|
||||||
|
* the rule name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
// ── Configuration ──────────────────────────────────────────────────────
|
||||||
|
const MAX_SOURCE_IGNORES = 12;
|
||||||
|
const MAX_TEST_IGNORES = 3;
|
||||||
|
|
||||||
|
/** Rule prefixes that must never be suppressed. */
|
||||||
|
const BANNED_PREFIXES = [
|
||||||
|
"lint/security/",
|
||||||
|
"lint/correctness/noGlobalObjectCalls",
|
||||||
|
"lint/correctness/noUnsafeFinally",
|
||||||
|
];
|
||||||
|
// ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)(?::\s*(.*))?/;
|
||||||
|
|
||||||
|
function findFiles() {
|
||||||
|
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestFile(path) {
|
||||||
|
return (
|
||||||
|
path.includes("__tests__/") ||
|
||||||
|
path.endsWith(".test.ts") ||
|
||||||
|
path.endsWith(".test.tsx")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
let sourceCount = 0;
|
||||||
|
let testCount = 0;
|
||||||
|
|
||||||
|
for (const file of findFiles()) {
|
||||||
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const match = lines[i].match(IGNORE_PATTERN);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const rule = match[1];
|
||||||
|
const justification = (match[2] ?? "").trim();
|
||||||
|
const loc = `${file}:${i + 1}`;
|
||||||
|
|
||||||
|
// Count by category
|
||||||
|
if (isTestFile(file)) {
|
||||||
|
testCount++;
|
||||||
|
} else {
|
||||||
|
sourceCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banned rules
|
||||||
|
for (const prefix of BANNED_PREFIXES) {
|
||||||
|
if (rule.startsWith(prefix)) {
|
||||||
|
console.error(`BANNED: ${loc} — ${rule} must not be suppressed`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Justification required
|
||||||
|
if (!justification) {
|
||||||
|
console.error(
|
||||||
|
`MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratcheting caps
|
||||||
|
if (sourceCount > MAX_SOURCE_IGNORES) {
|
||||||
|
console.error(
|
||||||
|
`SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testCount > MAX_TEST_IGNORES) {
|
||||||
|
console.error(
|
||||||
|
`TEST CAP EXCEEDED: ${testCount} biome-ignore comments in tests (max ${MAX_TEST_IGNORES}). Fix issues and lower the cap.`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(
|
||||||
|
`biome-ignore: ${sourceCount} source (max ${MAX_SOURCE_IGNORES}), ${testCount} test (max ${MAX_TEST_IGNORES})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(`\n${errors} problem(s) found.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("All checks passed.");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user