react-hooks/refs 245 -> 31 hits and 93 -> 29 skipped functions in the register and the eslint comment. The canonical-patterns table gets the latest-value row (useEffectEvent; a handler or timer: useLatest / useStableCallback, never a render-phase ref write) and the popover / scroll-fade row (destructure the ref setter).
832 lines
42 KiB
JavaScript
832 lines
42 KiB
JavaScript
import js from "@eslint/js";
|
||
import tseslint from "typescript-eslint";
|
||
import reactHooks from "eslint-plugin-react-hooks";
|
||
import reactRefresh from "eslint-plugin-react-refresh";
|
||
import prettier from "eslint-config-prettier";
|
||
import globals from "globals";
|
||
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
||
import vitest from "@vitest/eslint-plugin";
|
||
import playwright from "eslint-plugin-playwright";
|
||
|
||
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
|
||
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
|
||
// sprint nothing ran to make them mean anything. tsc already owns unused vars, undefined names
|
||
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
|
||
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
|
||
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
|
||
//
|
||
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
|
||
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
|
||
//
|
||
// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
|
||
// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
|
||
// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
|
||
// a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory).
|
||
|
||
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
|
||
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
|
||
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
|
||
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
|
||
const NETWORK_OWNERS = {
|
||
fetch: "src/lib/http.ts",
|
||
XMLHttpRequest: "src/lib/api/core.ts",
|
||
WebSocket: "src/lib/messagesSocket.ts",
|
||
EventSource: null,
|
||
};
|
||
const networkGlobals = (exempt) =>
|
||
Object.entries(NETWORK_OWNERS)
|
||
.filter(([name]) => name !== exempt)
|
||
.map(([name, owner]) => ({
|
||
name,
|
||
message: owner
|
||
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
|
||
: `No \`${name}\` without a logged owner module (E22 S4).`,
|
||
}));
|
||
const networkProperties = [
|
||
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
||
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
||
{
|
||
object: "navigator",
|
||
property: "sendBeacon",
|
||
message: "Use lib/api/core.ts `beacon` (E22 S4).",
|
||
},
|
||
];
|
||
|
||
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
||
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
||
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
||
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
||
const jsxCommentText = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
||
},
|
||
create(context) {
|
||
return {
|
||
JSXText(node) {
|
||
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
|
||
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
|
||
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
|
||
// keeps the focus; this is what stops a new raw one from reopening the hole.
|
||
const focusSafeDisabled = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
|
||
},
|
||
},
|
||
create(context) {
|
||
return {
|
||
JSXOpeningElement(node) {
|
||
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
|
||
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
|
||
context.report({ node, messageId: "raw" });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
|
||
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
|
||
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
|
||
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
|
||
// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
|
||
// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere.
|
||
//
|
||
// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton
|
||
// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read
|
||
// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to
|
||
// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes.
|
||
const localeFormat = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.",
|
||
order:
|
||
"Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.",
|
||
singleton:
|
||
"Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.",
|
||
},
|
||
},
|
||
create(context) {
|
||
const path = context.filename.split("\\").join("/");
|
||
if (/\.test\.tsx?$/.test(path)) return {};
|
||
// lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
|
||
// is a clock face with its own 12/24h switch, not a date.
|
||
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
|
||
const collates = /\/src\/lib\/collate\.ts$/.test(path);
|
||
const component = /\/src\/components\//.test(path);
|
||
const importedFromI18n = (node) => {
|
||
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
|
||
const v = scope.set.get(node.name);
|
||
if (v)
|
||
return v.defs.some(
|
||
(d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value),
|
||
);
|
||
}
|
||
return false;
|
||
};
|
||
return {
|
||
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
|
||
node,
|
||
) {
|
||
if (!formats) context.report({ node, messageId: "date" });
|
||
},
|
||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"(
|
||
node,
|
||
) {
|
||
if (!formats) context.report({ node, messageId: "date" });
|
||
},
|
||
"MemberExpression[object.name='Intl'][property.name='Collator']"(node) {
|
||
if (!collates) context.report({ node, messageId: "order" });
|
||
},
|
||
"CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) {
|
||
context.report({ node, messageId: "order" });
|
||
},
|
||
"MemberExpression[property.name='language'][object.type='Identifier']"(node) {
|
||
if (component && importedFromI18n(node.object))
|
||
context.report({ node, messageId: "singleton" });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
||
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
||
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
||
// side panel, a toast) are built from the primitives like any feature component.
|
||
const BUTTON_PRIMITIVES = new Set([
|
||
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
||
"Button.tsx",
|
||
"IconButton.tsx",
|
||
"Pressable.tsx",
|
||
"Chip.tsx",
|
||
"DragHandle.tsx",
|
||
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
|
||
"SegmentedToggle.tsx",
|
||
"Tabs.tsx",
|
||
"form.tsx", // Switch
|
||
"PriorityStepper.tsx",
|
||
"SortControl.tsx",
|
||
"SelectTrigger.tsx", // the dropdown trigger (A5)
|
||
"FilterSelect.tsx", // its option rows
|
||
"Combobox.tsx", // the field's chevron (A4)
|
||
]);
|
||
const buttonPrimitive = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
|
||
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
|
||
},
|
||
},
|
||
create(context) {
|
||
const path = context.filename.split("\\").join("/");
|
||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
|
||
return {
|
||
JSXOpeningElement(node) {
|
||
if (node.name.type !== "JSXIdentifier") return;
|
||
const name = node.name.name;
|
||
if (name === "button" || name === "FocusSafeButton")
|
||
context.report({ node, messageId: "raw", data: { name } });
|
||
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
|
||
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
|
||
else if (
|
||
/^[a-z]/.test(name) &&
|
||
node.attributes.some(
|
||
(a) =>
|
||
a.type === "JSXAttribute" &&
|
||
a.name.name === "role" &&
|
||
a.value?.type === "Literal" &&
|
||
a.value.value === "button",
|
||
)
|
||
)
|
||
context.report({ node, messageId: "role", data: { name } });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
|
||
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
|
||
// file picker has no look (it is hidden behind a Button), so it passes.
|
||
const INPUT_PRIMITIVES = new Set([
|
||
"Input.tsx",
|
||
"NativeControls.tsx",
|
||
"PriorityStepper.tsx", // the number box laid over the stepper's own face
|
||
]);
|
||
const inputPrimitive = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
|
||
},
|
||
},
|
||
create(context) {
|
||
const path = context.filename.split("\\").join("/");
|
||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
|
||
return {
|
||
JSXOpeningElement(node) {
|
||
if (node.name.type !== "JSXIdentifier") return;
|
||
const name = node.name.name;
|
||
if (name !== "input" && name !== "textarea") return;
|
||
const type = node.attributes.find(
|
||
(a) => a.type === "JSXAttribute" && a.name.name === "type",
|
||
);
|
||
const v = type?.value?.type === "Literal" ? type.value.value : null;
|
||
if (name === "input" && (v === "file" || v === "hidden")) return;
|
||
context.report({ node, messageId: "raw", data: { name } });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
|
||
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
|
||
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
|
||
// role table and the control primitives that paint a glass BUTTON face.
|
||
const SURFACE_OWNERS = new Set([
|
||
"surfaces.ts",
|
||
"buttonStyles.ts", // the `glass` button face
|
||
"SelectTrigger.tsx",
|
||
"fieldStyles.ts", // the header search floats over the page
|
||
]);
|
||
const GLASS_TIER = /^glass(-card|-menu)?$/;
|
||
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
|
||
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
|
||
// variable, and `shadow-float` IS the surface's shadow, so both pass.
|
||
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
|
||
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
|
||
const isSurfaceRole = (n) =>
|
||
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
|
||
const surfaceRole = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
|
||
shadow:
|
||
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
|
||
},
|
||
},
|
||
create(context) {
|
||
const path = context.filename.split("\\").join("/");
|
||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
|
||
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
|
||
const check = (node, text) => {
|
||
if (typeof text !== "string") return;
|
||
const words = text.split(/\s+/).filter(Boolean);
|
||
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
|
||
// fragment that a space joins to its neighbour.
|
||
if (words.length < 2 && text === text.trim()) return;
|
||
const tier = words.find((w) => GLASS_TIER.test(w));
|
||
if (tier) context.report({ node, messageId: "raw", data: { tier } });
|
||
};
|
||
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
|
||
const shadowBeside = (node, parts) => {
|
||
if (!parts.some(isSurfaceRole)) return;
|
||
for (const part of parts) {
|
||
const text =
|
||
part.type === "TemplateElement"
|
||
? part.value.cooked
|
||
: part.type === "Literal" && typeof part.value === "string"
|
||
? part.value
|
||
: "";
|
||
const shadow = text.split(/\s+/).find(isShadowSize);
|
||
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
|
||
}
|
||
};
|
||
return {
|
||
Literal(node) {
|
||
check(node, node.value);
|
||
},
|
||
TemplateElement(node) {
|
||
check(node, node.value.cooked);
|
||
},
|
||
TemplateLiteral(node) {
|
||
shadowBeside(node, [...node.quasis, ...node.expressions]);
|
||
},
|
||
CallExpression(node) {
|
||
shadowBeside(node, node.arguments);
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
||
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
||
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
||
// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes.
|
||
const A11Y_TEXT = new Set([
|
||
"aria-label",
|
||
"aria-description",
|
||
"aria-placeholder",
|
||
"aria-roledescription",
|
||
"aria-valuetext",
|
||
"title",
|
||
"placeholder",
|
||
"alt",
|
||
]);
|
||
const literalText = (v) => {
|
||
if (!v) return null;
|
||
if (v.type === "Literal") return typeof v.value === "string" ? v.value : null;
|
||
if (v.type !== "JSXExpressionContainer") return null;
|
||
const e = v.expression;
|
||
if (e.type === "Literal") return typeof e.value === "string" ? e.value : null;
|
||
if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked;
|
||
return null;
|
||
};
|
||
const literalA11yText = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." },
|
||
},
|
||
create(context) {
|
||
return {
|
||
JSXAttribute(node) {
|
||
const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
|
||
if (!name || !A11Y_TEXT.has(name)) return;
|
||
const text = literalText(node.value);
|
||
if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text))
|
||
context.report({ node, messageId: "literal", data: { name } });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four
|
||
// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that
|
||
// replaces it, and this is what keeps the next sortable list from skipping it.
|
||
const sortableA11y = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
context: "A <DndContext> needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).",
|
||
item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.",
|
||
},
|
||
},
|
||
create(context) {
|
||
const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts");
|
||
return {
|
||
JSXOpeningElement(node) {
|
||
if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return;
|
||
if (
|
||
!node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility")
|
||
)
|
||
context.report({ node, messageId: "context" });
|
||
},
|
||
ImportDeclaration(node) {
|
||
if (own || node.source.value !== "@dnd-kit/sortable") return;
|
||
for (const sp of node.specifiers)
|
||
if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable")
|
||
context.report({ node: sp, messageId: "item" });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
|
||
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
|
||
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
|
||
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
|
||
const glyphLink = {
|
||
meta: {
|
||
type: "problem",
|
||
messages: {
|
||
glyph:
|
||
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
|
||
},
|
||
},
|
||
create(context) {
|
||
const hasText = (children) =>
|
||
children.some(
|
||
(c) =>
|
||
(c.type === "JSXText" && /\S/.test(c.value)) ||
|
||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
|
||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
|
||
);
|
||
return {
|
||
JSXElement(node) {
|
||
const open = node.openingElement;
|
||
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
|
||
return;
|
||
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
|
||
},
|
||
};
|
||
},
|
||
};
|
||
const localRules = {
|
||
rules: {
|
||
"glyph-link": glyphLink,
|
||
"sortable-a11y": sortableA11y,
|
||
"jsx-comment-text": jsxCommentText,
|
||
"focus-safe-disabled": focusSafeDisabled,
|
||
"button-primitive": buttonPrimitive,
|
||
"locale-format": localeFormat,
|
||
"input-primitive": inputPrimitive,
|
||
"surface-role": surfaceRole,
|
||
"literal-a11y-text": literalA11yText,
|
||
},
|
||
};
|
||
|
||
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
|
||
// `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the integer-only pattern.
|
||
const BOX_PX =
|
||
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
||
const BOX_PX_MESSAGE =
|
||
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||
// …and a grid's auto-fill column MINIMUM in px (`minmax(150px,1fr)`): the cards' text grows with the
|
||
// slider while the column floor stays, so the cards get cramped at a large text size (E30 review). A
|
||
// fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's measured `minCol` (a number).
|
||
const GRID_MIN_PX = "/minmax\\([\\d.]+px,/";
|
||
const GRID_MIN_PX_MESSAGE =
|
||
"px-frozen box size doesn't scale with --font-scale: give a grid column's minimum in rem (minmax(9.375rem,1fr)), not minmax(Npx,…).";
|
||
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
|
||
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
|
||
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
|
||
const Z_RAW_MESSAGE =
|
||
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
|
||
|
||
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
|
||
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
|
||
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
|
||
// hover, C1) and an edged pill's accent hover (F14) are not matched.
|
||
const HOVER_SWAP =
|
||
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
|
||
const HOVER_SWAP_MESSAGE =
|
||
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
|
||
|
||
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
|
||
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
|
||
// media scrims and the text on them, the same in every theme.
|
||
const PALETTE =
|
||
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
|
||
const PALETTE_MESSAGE =
|
||
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||
|
||
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
|
||
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
|
||
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
|
||
// radii match.
|
||
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
|
||
const EDGED_LABEL_MESSAGE =
|
||
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
|
||
|
||
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
|
||
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
|
||
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
|
||
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
|
||
const SPLIT_CLASS_MESSAGE =
|
||
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||
|
||
// Applies to EVERY linted file: a suppression says which rule and why. A bare
|
||
// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
|
||
// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
|
||
const suppressionRules = {
|
||
plugins: { "@eslint-community/eslint-comments": eslintComments },
|
||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||
rules: {
|
||
"@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }],
|
||
"@eslint-community/eslint-comments/no-unlimited-disable": "error",
|
||
},
|
||
};
|
||
|
||
// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
|
||
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
|
||
// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget
|
||
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
|
||
// unless asked to (`throwOnError`), so those calls are known-safe by name.
|
||
const typedRules = {
|
||
"@typescript-eslint/no-floating-promises": [
|
||
"error",
|
||
{
|
||
allowForKnownSafeCalls: [
|
||
{
|
||
from: "package",
|
||
package: "@tanstack/query-core",
|
||
name: [
|
||
"invalidateQueries",
|
||
"refetchQueries",
|
||
"cancelQueries",
|
||
"resetQueries",
|
||
"prefetchQuery",
|
||
"prefetchInfiniteQuery",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
"@typescript-eslint/no-deprecated": "error",
|
||
};
|
||
|
||
export default tseslint.config(
|
||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||
suppressionRules,
|
||
{
|
||
files: ["src/**/*.{ts,tsx}"],
|
||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||
languageOptions: {
|
||
ecmaVersion: 2022,
|
||
globals: { ...globals.browser },
|
||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||
},
|
||
plugins: {
|
||
"react-hooks": reactHooks,
|
||
"react-refresh": reactRefresh,
|
||
local: localRules,
|
||
},
|
||
rules: {
|
||
...reactHooks.configs.recommended.rules,
|
||
...typedRules,
|
||
// The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
|
||
// the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
|
||
// error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
|
||
// this shape yet" (31 try statements, each suppressed inline until E32 S3 rewrites them); the
|
||
// rest had 0 hits when switched on and are tripwires. The full per-function picture is
|
||
// components/compilerCoverage.contract.test.ts.
|
||
"react-hooks/todo": "error",
|
||
"react-hooks/memo-dependencies": "error",
|
||
"react-hooks/rule-suppression": "error",
|
||
"react-hooks/invariant": "error",
|
||
"react-hooks/syntax": "error",
|
||
"react-hooks/void-use-memo": "error",
|
||
"react-hooks/component-hook-factories": "error",
|
||
"react-hooks/capitalized-calls": "error",
|
||
"react-hooks/hooks": "error",
|
||
"react-hooks/memoized-effect-dependencies": "error",
|
||
"react-hooks/exhaustive-effect-dependencies": "error",
|
||
"react-hooks/no-deriving-state-in-effects": "error",
|
||
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
||
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
|
||
// triaged rather than bulk-suppressed, because the two answers are different:
|
||
//
|
||
// react-hooks/refs 217 — OFF, see below.
|
||
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
|
||
// sprint and E27 S2 burned the file down to nothing: what survives is
|
||
// an inline directive at the site, each naming the invariant it
|
||
// protects. There is DELIBERATELY no suppressions file any more — a
|
||
// bulk `--suppress-all` would hide the next violation instead of
|
||
// answering it, and it cannot hold a warning-level rule anyway.
|
||
//
|
||
// `refs` is OFF — the one rule here switched off while it has real hits, and recorded as such
|
||
// in docs/gates.md. E27 turned it off calling 153 of its 217 hits false positives (the popover
|
||
// engine's `pop.refs.setReference` / `getReferenceProps()`, a popover handle passed as a prop)
|
||
// and the other ~64 real render-phase `someRef.current = x` writes. The E32 audit showed the
|
||
// triage was wrong: the rule and the React Compiler share one analysis, so every one of those
|
||
// "false positives" is a component the compiler SKIPS — 21 popover components among them, and
|
||
// Channels' 0.9 s row menu was the bill. It stays off only because 245 hits (measured E32 S1)
|
||
// could not be fixed in one sprint. E32 S3 took them to 31: the popover engine is read through
|
||
// destructuring (`const { setReference } = pop.refs`), every render-phase write moved to
|
||
// `useEffectEvent` / `useLatest`. The rest sit in PlexPlayer and PlayerModal, behind the
|
||
// try/finally and suppression skips; until they are gone, compilerCoverage.contract.test.ts pins
|
||
// every skipped function (none may be added), and this comes back on.
|
||
"react-hooks/refs": "off",
|
||
"local/jsx-comment-text": "error",
|
||
"local/focus-safe-disabled": "error",
|
||
"local/button-primitive": "error",
|
||
"local/locale-format": "error",
|
||
"local/input-primitive": "error",
|
||
"local/surface-role": "error",
|
||
"local/literal-a11y-text": "error",
|
||
"local/glyph-link": "error",
|
||
"local/sortable-a11y": "error",
|
||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||
"@typescript-eslint/no-unused-vars": "off",
|
||
"no-undef": "off",
|
||
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
|
||
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
|
||
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
|
||
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
|
||
"@typescript-eslint/no-explicit-any": "error",
|
||
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
|
||
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
|
||
"no-console": "error",
|
||
"no-restricted-globals": ["error", ...networkGlobals(null)],
|
||
"no-restricted-properties": ["error", ...networkProperties],
|
||
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
|
||
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
|
||
"@typescript-eslint/no-unused-expressions": [
|
||
"error",
|
||
{ allowShortCircuit: true, allowTernary: true },
|
||
],
|
||
// Source-shape rules, each with its reason right above it (contract-tested in
|
||
// lib/*.contract.test.ts).
|
||
"no-restricted-syntax": [
|
||
"error",
|
||
// A next-page fetch called bare CANCELS the one in flight and sends the same cursor again;
|
||
// our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×).
|
||
// lib/nextPage.ts NEXT_PAGE makes the second call join the first.
|
||
{
|
||
selector:
|
||
"CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])",
|
||
message:
|
||
"fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).",
|
||
},
|
||
// `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is
|
||
// disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked.
|
||
{
|
||
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||
message:
|
||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||
},
|
||
{
|
||
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||
message:
|
||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||
},
|
||
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
|
||
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
|
||
// hover colour of the button and chip primitives was dead that way (E28 audit).
|
||
{
|
||
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
|
||
message: SPLIT_CLASS_MESSAGE,
|
||
},
|
||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||
// 2026-09 debt burn replaced them, and this keeps the next one out.
|
||
{
|
||
selector: "JSXOpeningElement[name.name='select']",
|
||
message:
|
||
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
||
},
|
||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
|
||
// scale with the Settings --font-scale slider while its rem neighbours do; use
|
||
// `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and
|
||
// template-literal chunks; rem arbitraries (text-[0.75rem]) don't match (no "px").
|
||
{
|
||
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
|
||
message:
|
||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||
},
|
||
{
|
||
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
|
||
message:
|
||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||
},
|
||
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
||
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
||
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||
{ selector: `Literal[value=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
|
||
{ selector: `TemplateElement[value.raw=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
|
||
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||
{
|
||
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
|
||
message: EDGED_LABEL_MESSAGE,
|
||
},
|
||
{
|
||
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
|
||
message: EDGED_LABEL_MESSAGE,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
||
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
||
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
||
// the first "just this once" import and the directory silently becomes another components/.
|
||
files: ["src/components/ui/**/*.{ts,tsx}"],
|
||
rules: {
|
||
"no-restricted-imports": [
|
||
"error",
|
||
{
|
||
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
||
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
||
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
||
patterns: [
|
||
{
|
||
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
||
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
||
regex: String.raw`^\.\./(?!\.\./)`,
|
||
message:
|
||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||
},
|
||
{
|
||
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
||
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
||
// because a sibling primitive is the one thing this path may legitimately name.
|
||
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
||
message:
|
||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||
},
|
||
{
|
||
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
||
// component uses, and a `/`-anchored pattern let exactly that one through.
|
||
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
||
message:
|
||
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
||
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
||
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
||
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
||
files: ["src/lib/**/*.{ts,tsx}"],
|
||
rules: {
|
||
"no-restricted-imports": [
|
||
"error",
|
||
{
|
||
patterns: [
|
||
{
|
||
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
|
||
regex: String.raw`^(\.\./)+components(/|$)`,
|
||
message:
|
||
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
// The network owners (see NETWORK_OWNERS), each exempt from its own global only.
|
||
...Object.entries(NETWORK_OWNERS)
|
||
.filter(([, owner]) => owner)
|
||
.map(([name, owner]) => ({
|
||
files: [owner],
|
||
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
||
})),
|
||
{
|
||
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
|
||
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
|
||
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||
rules: { "local/focus-safe-disabled": "off" },
|
||
},
|
||
{
|
||
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
||
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
||
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
||
files: ["src/design-lab/**/*.{ts,tsx}"],
|
||
rules: { "local/literal-a11y-text": "off" },
|
||
},
|
||
{
|
||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||
// …and render throwaway markup whose labels no user ever hears.
|
||
files: ["src/**/*.test.{ts,tsx}"],
|
||
plugins: { vitest },
|
||
rules: {
|
||
// A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
|
||
// (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
|
||
"vitest/no-focused-tests": "error",
|
||
"vitest/no-disabled-tests": "error",
|
||
"no-restricted-globals": "off",
|
||
"no-restricted-properties": "off",
|
||
"local/literal-a11y-text": "off",
|
||
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
|
||
"local/button-primitive": "off",
|
||
"local/input-primitive": "off",
|
||
"local/surface-role": "off",
|
||
"local/glyph-link": "off",
|
||
},
|
||
},
|
||
{
|
||
// Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
|
||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||
languageOptions: {
|
||
ecmaVersion: 2022,
|
||
globals: { ...globals.node },
|
||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||
},
|
||
rules: {
|
||
...typedRules,
|
||
// tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict +
|
||
// noUnusedLocals/Parameters).
|
||
"@typescript-eslint/no-unused-vars": "off",
|
||
"no-undef": "off",
|
||
},
|
||
},
|
||
{
|
||
files: ["e2e/**/*.ts"],
|
||
plugins: { playwright },
|
||
rules: {
|
||
"playwright/no-focused-test": "error",
|
||
"playwright/no-skipped-test": "error",
|
||
},
|
||
},
|
||
{
|
||
// The two plain-JS configs: this file and postcss's.
|
||
files: ["*.config.js"],
|
||
extends: [js.configs.recommended],
|
||
languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } },
|
||
},
|
||
prettier,
|
||
);
|