2026-10-07 15:11:35 +02:00
|
|
|
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: {
|
2026-10-10 02:30:01 +02:00
|
|
|
// 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'],
|
2026-10-07 15:11:35 +02:00
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|