/** * One flat config for the whole workspace. * * The repo shipped a `lint` script in all 13 packages that was `echo "(eslint * not yet wired)"`, and CONTRIBUTING said that was deliberate: "a green check * that runs no rules is worse than an absent one". That reasoning was right, * and it is why this config exists rather than a token one. Every rule below * earns its place by catching something this codebase has actually shipped. * * Type-aware rules run only where a tsconfig covers the files. They are the * expensive half and the valuable half: `no-floating-promises` is the rule for * the `void recordSecurityEvent(...)` contract this codebase relies on * everywhere, and a missed `await` in a money path is the shape of bug that * costs real money. */ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import globals from 'globals'; import reactHooks from 'eslint-plugin-react-hooks'; import nextPlugin from '@next/eslint-plugin-next'; import reactPlugin from 'eslint-plugin-react'; import jsxA11y from 'eslint-plugin-jsx-a11y'; import noEmDash from './tools/eslint/no-em-dash.js'; const local = { rules: { 'no-em-dash': noEmDash } }; export default tseslint.config( { ignores: [ '**/dist/**', '**/.next/**', '**/node_modules/**', '**/coverage/**', 'packages/*/dist/**', // Static and generated assets in any app's `public/`, including a // published OpenAPI document. Written without naming an app: the // OSS-strip tripwire refuses any surviving reference to a stripped one. '**/public/**', // Generated by prisma; not ours to lint. 'prisma/migrations/**', // Generated by Next on every build. Its triple-slash references are the // documented shape, and the file is overwritten anyway. '**/next-env.d.ts', ], }, js.configs.recommended, ...tseslint.configs.recommended, { plugins: { local }, languageOptions: { globals: { ...globals.node, ...globals.es2024 }, }, rules: { 'local/no-em-dash': 'error', // Unused code is the cheapest real signal there is. `_`-prefixed is the // documented escape hatch, matching how the codebase already writes // deliberately-unused parameters. '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', ignoreRestSiblings: true, }, ], // `any` is a warning, not an error: the codebase has legitimate `as never` // and `as unknown as X` seams at provider boundaries, and turning those // into a wall of errors on day one would mean the whole thing gets // disabled. Warnings do not fail CI; `lint-staged` runs with // `--max-warnings=0` so a file you touch has to come back clean. '@typescript-eslint/no-explicit-any': 'warn', // `catch {}` that swallows silently is how a bookkeeping failure becomes // invisible. Deliberate ones in this codebase carry a comment saying so; // this makes the deliberate ones explicit rather than indistinguishable. 'no-empty': ['error', { allowEmptyCatch: false }], 'no-console': ['warn', { allow: ['warn', 'error'] }], // Enabled BECAUSE the codebase already carried `eslint-disable` comments // naming them. Those suppressions were written against a config that // never existed, so each was an assertion nobody could check. Turning the // rules on makes every one of them either true or a build error, which is // the point of issue #497 rather than deleting the intent someone wrote. 'no-new': 'error', 'no-constant-condition': ['error', { checkLoops: false }], // Not enabled globally: every hit in this codebase is DEFENSIVE, either a // denylist entry naming the scheme or a test fixture asserting it is // refused. See the tests override below, which turns it off there. 'no-script-url': 'error', // Warn. Every current hit is a defensive initializer that a later branch // unconditionally overwrites (`let ttlSec = 0` before a try/catch that // sets it either way). The dead store is real, and rewriting eleven of // them across the brute-force limiter, the idempotency middleware and the // signature check to satisfy a style rule is risk without a return. // New ones are worth seeing; existing ones are not worth touching. 'no-useless-assignment': 'warn', eqeqeq: ['error', 'always', { null: 'ignore' }], 'no-var': 'error', 'prefer-const': ['error', { destructuring: 'all' }], }, }, // Type-aware rules for the API, where the money paths live. { files: ['apps/api/src/**/*.ts', 'apps/api/test/**/*.ts', 'apps/api/scripts/**/*.ts'], languageOptions: { parserOptions: { project: ['./apps/api/tsconfig.test.json'], tsconfigRootDir: import.meta.dirname, }, }, rules: { // THE rule this codebase needs. `recordSecurityEvent` is fire-and-forget // by contract and written `void recordSecurityEvent(...)`; without this, // a dropped `void` or a missing `await` on a money write is invisible. '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/await-thenable': 'error', // Reported, never auto-fixed. Its fixer removed `as object` from a // Prisma write where the assertion was load-bearing for // `InputJsonValue` assignability: the checker calls the assertion // redundant for the expression's own type while it still drives // overload resolution at the call site. Ten typecheck errors came out // of one `--fix` pass. Each of these needs a human to confirm the // assertion really is dead before it goes. '@typescript-eslint/no-unnecessary-type-assertion': 'off', // Off. 133 hits, and almost all are `async` for interface conformance: // a handler signature the framework awaits, or a service method whose // siblings are async. The rule cannot tell those from a genuine mistake, // and 133 warnings drown the ones that matter. '@typescript-eslint/require-await': 'off', }, }, // React surfaces. { // Every app EXCEPT the API, selected by shape rather than by name: the // OSS-strip tripwire refuses any surviving reference to a stripped app, so // this cannot list them. `apps/api` has no JSX, and the react/next rules // only fire on JSX or hooks, so including it costs nothing. files: ['apps/*/src/**/*.{ts,tsx}', 'apps/*/test/**/*.{ts,tsx}'], // The API is excluded by name, which is safe: it survives the strip, so // the tripwire that refuses references to a STRIPPED app does not apply. // It has no JSX, and `@next/next` rules like `no-assign-module-variable` // fire on plain server code that has nothing to do with Next. ignores: ['apps/api/**'], plugins: { 'react-hooks': reactHooks, '@next/next': nextPlugin, react: reactPlugin, 'jsx-a11y': jsxA11y, }, settings: { react: { version: 'detect' } }, languageOptions: { globals: { ...globals.browser, ...globals.node }, }, rules: { ...reactHooks.configs.recommended.rules, ...nextPlugin.configs.recommended.rules, // Next's own image guidance is advisory here: the marketing site // deliberately ships plain in places for build-time simplicity. '@next/next/no-img-element': 'warn', // `react` and `jsx-a11y` are here because the codebase already carried // `eslint-disable` comments naming their rules, written against a config // that never existed. Those suppressions asserted something nobody could // check. Installing the plugins makes each one either true or a build // error, which is the whole point of the exercise. // Warn, not error. Every current use is JSON-LD (`JSON.stringify` of a // schema.org object into a