import js from '@eslint/js' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import globals from 'globals' import tseslint from 'typescript-eslint' /** * ESLint flat configuration. * * Two decisions are worth stating: * * 1. Type-aware linting is on (`projectService`), because the rules that catch * real bugs in a codebase using `unknown` at its boundaries need type * information. This is also why TypeScript is pinned to 6.x: see ADR 0004. * * 2. `--max-warnings 0` in the npm script. A warning that is tolerated is a * warning nobody reads, so the build is the place it stops. */ export default tseslint.config( { ignores: ['dist', 'coverage', 'node_modules', '*.config.js', '*.config.ts'], }, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked, { languageOptions: { parserOptions: { // The app build project excludes test files (so the production image build // never compiles the SDK-coupled integration test). ESLint still needs type // info for every file it lints, so point it at BOTH projects explicitly // rather than relying on projectService to discover the test project. project: ['./tsconfig.app.json', './tsconfig.test.json'], tsconfigRootDir: import.meta.dirname, }, globals: { ...globals.browser, ...globals.es2022 }, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], // --- correctness ----------------------------------------------------- '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': [ 'error', // A promise-returning handler is the normal React pattern; the void // operator is the fix for the cases that are actually a mistake. { checksVoidReturn: { arguments: false, attributes: false } }, ], '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/restrict-template-expressions': [ 'error', { allowNumber: true, allowBoolean: true }, ], '@typescript-eslint/switch-exhaustiveness-check': 'error', // `||` is used deliberately where an empty string should fall through, // which is exactly what `??` does not do. '@typescript-eslint/prefer-nullish-coalescing': 'off', // A type alias reads better than an interface for everything in this // codebase, and the two are interchangeable in practice. '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/no-unnecessary-type-assertion': 'error', '@typescript-eslint/consistent-type-imports': [ 'error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, ], // --- readability ----------------------------------------------------- // Both return-type annotation rules are off by design. // // `tsc` with `strict` already enforces a type at every boundary: a // component with a wrongly-typed prop is a compile error whether or not // its return type is written out. What the ESLint rules add is annotation // churn — one per callback, and `(): JSX.Element =>` on components that // legitimately start returning null and then need the annotation widened. // That is a maintenance cost with no defect class behind it. // // What *is* enforced instead: every component declares a typed props // interface, every exported non-component function carries a JSDoc // `@returns`, and `strict` plus `noUncheckedIndexedAccess` run on every // build. See docs/standards.md. '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', // An empty function is a legitimate no-op: a ResizeObserver stub, an // intentionally ignored error, a deliberate empty effect body. '@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions', 'methods'] }], // A leading or trailing underscore is the convention for an intentionally // unused binding, and no-unused-vars above is configured to honour it. The // two rules have to agree or every intentionally-ignored value is a // naming error. '@typescript-eslint/naming-convention': [ 'error', { selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], leadingUnderscore: 'allow', trailingUnderscore: 'allow', }, { selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' }, { selector: 'typeLike', format: ['PascalCase'] }, ], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }, ], '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 'no-console': ['warn', { allow: ['warn', 'error'] }], eqeqeq: ['error', 'always', { null: 'ignore' }], 'prefer-const': 'error', 'object-shorthand': 'error', }, }, // Tests are allowed to reach into internals and to use `any` in a double. { files: ['**/*.test.ts', '**/*.test.tsx', 'src/test/**'], rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/unbound-method': 'off', 'no-console': 'off', }, }, )