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"; import path from "node:path"; import { fileURLToPath } from "node:url"; // Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the // hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this // config first met are all gone (0 left); what is still suppressed is suppressed inline, // one site at a time, each naming its invariant. 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. // // 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 `-- ` description (eslint-comments/require-description makes that mandatory). // 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). 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; a global with no owner (null) has no exempt file at all. const NETWORK_OWNERS = { fetch: "src/lib/http.ts", XMLHttpRequest: "src/lib/api/core.ts", WebSocket: null, 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 — a request outside it never reaches the client log.` : `No \`${name}\` without a logged owner module.`, })); const networkProperties = [ { object: "window", property: "fetch", message: "Use lib/http.ts `send`." }, { object: "globalThis", property: "fetch", message: "Use lib/http.ts `send`." }, { object: "navigator", property: "sendBeacon", message: "Use lib/api/core.ts `beacon`.", }, ]; // 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" }); }, }; }, }; // 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); // Every folder that holds components (CLAUDE.md "Architecture shape"), not lib/ below them. const component = /\/src\/(components|shell|modules|features|watch)\//.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" }); }, }; }, }; // 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. // // The same rule keeps a raw `