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"; // 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). // 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).", }, ]; export default tseslint.config( { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { files: ["src/**/*.{ts,tsx}"], extends: [js.configs.recommended, ...tseslint.configs.recommended], languageOptions: { ecmaVersion: 2022, globals: { ...globals.browser }, }, plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, }, linterOptions: { reportUnusedDisableDirectives: "error", }, rules: { ...reactHooks.configs.recommended.rules, "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; // 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 }, ], // 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"). Font-size `text-` only — not w-/h-/p-. "no-restricted-syntax": [ "error", { 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).", }, ], }, }, { // 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)] }, })), { // Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. files: ["src/**/*.test.{ts,tsx}"], rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" }, }, prettier, );