import js from '@eslint/js'; import typescript from '@typescript-eslint/eslint-plugin'; import typescriptParser from '@typescript-eslint/parser'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactCompiler from 'eslint-plugin-react-compiler'; import integrationBoundaries from './eslint-rules/integration-boundaries.mjs'; import moduleSdkBoundary from './eslint-rules/module-sdk-boundary.mjs'; import { MAX_LINES_TODO } from './eslint-rules/max-lines-todo.mjs'; import { ELSE_RETURN, NESTED_TERNARY, NON_NULL_ASSERTION, OBJECT_LITERAL_ASSERTION, PREFER_CONST, TYPE_IMPORTS, } from './eslint-rules/cleanliness-todo.mjs'; import { NULLISH_COALESCING, OPTIONAL_CHAIN, SWITCH_EXHAUSTIVE, UNNECESSARY_ASSERTION, UNNECESSARY_CONDITION, } from './eslint-rules/typed-lint-todo.mjs'; export default [ js.configs.recommended, // Node build config (esbuild) { files: ['api/esbuild.config.js'], languageOptions: { ecmaVersion: 'latest', sourceType: 'script', globals: { require: 'readonly', module: 'readonly', process: 'readonly', __dirname: 'readonly', }, }, }, // Backend TypeScript files (api/src/**/*.ts) { files: ['api/src/**/*.ts'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: './api/tsconfig.json', }, globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', URL: 'readonly', __dirname: 'readonly', __filename: 'readonly', module: 'readonly', require: 'readonly', exports: 'readonly', global: 'readonly', globalThis: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', setImmediate: 'readonly', clearImmediate: 'readonly', Promise: 'readonly', NodeJS: 'readonly', }, }, plugins: { '@typescript-eslint': typescript, }, rules: { ...typescript.configs.recommended.rules, '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/explicit-function-return-type': 'off', // Use `log` from utils/logger. Stray console.log/debug dumps reached // production logs unfiltered; console.warn/error remain in catch blocks. 'no-console': ['error', { allow: ['warn', 'error'] }], }, }, // Scripts TypeScript files (scripts/**/*.ts) { files: ['scripts/**/*.ts'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', // No project requirement - scripts don't need strict type checking }, globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', URL: 'readonly', __dirname: 'readonly', __filename: 'readonly', module: 'readonly', require: 'readonly', exports: 'readonly', global: 'readonly', globalThis: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', setImmediate: 'readonly', clearImmediate: 'readonly', Promise: 'readonly', NodeJS: 'readonly', // Some scripts evaluate browser-side code snippets (e.g. Playwright helpers) window: 'readonly', }, }, plugins: { '@typescript-eslint': typescript, }, rules: { ...typescript.configs.recommended.rules, // Scripts are allowed to have helpers and any-typed data structures '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/explicit-function-return-type': 'off', 'no-console': 'off', }, }, // Repo tooling scripts (scripts/**/*.mjs, tests/*.mjs) — plain Node ESM. { files: ['scripts/**/*.mjs', 'tests/*.mjs'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { console: 'readonly', process: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', URL: 'readonly', }, }, rules: { 'no-console': 'off', }, }, // Frontend tooling scripts (client/scripts/**/*.mjs) // These run in Node, but may use modern Web APIs available in Node (fetch, AbortController, etc). { files: ['client/scripts/**/*.mjs'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { console: 'readonly', process: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', fetch: 'readonly', AbortController: 'readonly', structuredClone: 'readonly', Promise: 'readonly', }, }, rules: { 'no-console': 'off', }, }, // Tests TypeScript files (tests/**/*.ts) { files: ['tests/**/*.ts'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', // No project requirement - tests don't need strict type checking }, globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', URL: 'readonly', __dirname: 'readonly', __filename: 'readonly', module: 'readonly', require: 'readonly', exports: 'readonly', global: 'readonly', globalThis: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', setImmediate: 'readonly', clearImmediate: 'readonly', Promise: 'readonly', NodeJS: 'readonly', // Playwright / test globals test: 'readonly', expect: 'readonly', describe: 'readonly', it: 'readonly', beforeAll: 'readonly', afterAll: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', page: 'readonly', browser: 'readonly', context: 'readonly', // Browser-like globals used in tests & helpers window: 'readonly', document: 'readonly', navigator: 'readonly', localStorage: 'readonly', sessionStorage: 'readonly', URLSearchParams: 'readonly', CustomEvent: 'readonly', }, }, plugins: { '@typescript-eslint': typescript, }, rules: { ...typescript.configs.recommended.rules, // Tests are allowed to have helpers and any-typed fixtures '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/explicit-function-return-type': 'off', 'no-console': 'off', }, }, // Frontend React files (client/**/*.tsx, client/**/*.ts) { files: ['client/**/*.{ts,tsx}'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true, }, }, globals: { console: 'readonly', document: 'readonly', window: 'readonly', navigator: 'readonly', localStorage: 'readonly', sessionStorage: 'readonly', fetch: 'readonly', FormData: 'readonly', HTMLElement: 'readonly', HTMLDivElement: 'readonly', HTMLAudioElement: 'readonly', HTMLInputElement: 'readonly', HTMLSelectElement: 'readonly', HTMLTextAreaElement: 'readonly', HTMLImageElement: 'readonly', SVGSVGElement: 'readonly', DOMParser: 'readonly', Element: 'readonly', Audio: 'readonly', URLSearchParams: 'readonly', URL: 'readonly', CustomEvent: 'readonly', MouseEvent: 'readonly', Event: 'readonly', DOMException: 'readonly', DocumentFragment: 'readonly', File: 'readonly', FileReader: 'readonly', alert: 'readonly', requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly', ResizeObserver: 'readonly', globalThis: 'readonly', RequestInit: 'readonly', Response: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', Promise: 'readonly', __APP_VERSION__: 'readonly', // Defined by Vite }, }, plugins: { '@typescript-eslint': typescript, react, 'react-hooks': reactHooks, 'react-compiler': reactCompiler, }, rules: { ...typescript.configs.recommended.rules, ...react.configs.recommended.rules, ...reactHooks.configs.recommended.rules, 'react/react-in-jsx-scope': 'off', // Not needed in React 17+ 'react/prop-types': 'off', // Using TypeScript 'react/no-unescaped-entities': 'off', // Allow quotes in text content '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-expressions': [ 'error', { allowShortCircuit: true, allowTernary: true }, ], 'no-console': 'off', // Disable React Compiler warnings – we still keep core React/TS rules 'react-compiler/react-compiler': 'off', }, settings: { react: { version: 'detect', }, }, }, // Game integration boundaries (3.0 module split). Core code reaches a game // only through integrations/registry and integrations/types; an integration // never imports another one or the registry; api/src/core never imports // rconService or the MatchZy Enhanced event types by any path. An error everywhere, // with no allowlist. See eslint-rules/integration-boundaries.mjs. { files: ['api/src/**/*.ts', 'client/src/**/*.{ts,tsx}'], plugins: { local: integrationBoundaries, }, rules: { 'local/integration-boundaries': 'error', }, }, // Module SDK boundary (item 8c). A client game module reaches platform code // only through client/src/module-sdk and integrations/types, and uses the // host's copy of the shared packages. An error, so no new bypass creeps in. // See eslint-rules/module-sdk-boundary.mjs. { files: ['client/src/integrations/**/*.{ts,tsx}'], plugins: { 'module-sdk': moduleSdkBoundary, }, rules: { 'module-sdk/module-sdk-boundary': 'error', }, }, // File length. A file over 450 lines of code (blank lines and comments not // counted) is too long to keep in your head: split it. Files from before the // rule are listed; a PR that splits one takes it off the list. { files: ['api/src/**/*.ts', 'client/src/**/*.{ts,tsx}'], ignores: MAX_LINES_TODO, rules: { 'max-lines': ['error', { max: 450, skipBlankLines: true, skipComments: true }], }, }, // Typed internals. Values our own code makes or stores carry their real // type, so mistakes show up here instead of as runtime checks. Data from // outside (a request body, a fetch, a server message, a feed) comes in as // `Untrusted` (utils/untrusted) and is checked once where it arrives. // A caught error keeps `unknown` (anything can be thrown): in `catch`, in // `.catch()` callbacks and in a parameter named error/err/e/reason/cause. { files: ['api/src/**/*.ts', 'client/src/**/*.{ts,tsx}'], rules: { 'no-restricted-syntax': [ 'error', { selector: 'TSUnknownKeyword:not(CatchClause TSUnknownKeyword):not(CallExpression[callee.property.name="catch"] TSUnknownKeyword):not(Identifier[name=/^(e|err|error|reason|cause)$/] > TSTypeAnnotation > TSUnknownKeyword)', message: 'Give it its real type. Outside data is `Untrusted` (utils/untrusted), checked once where it arrives.', }, { selector: 'TSAsExpression[expression.type="TSAsExpression"][expression.typeAnnotation.type="TSUnknownKeyword"]', message: '`as unknown as` switches type checking off. Fix the types instead.', }, ], }, }, // Cleanliness. One rule per object so each can skip its own list of files // from before the rule (eslint-rules/cleanliness-todo.mjs); new files follow // all of them. max-depth is a warning until we agree a limit. ...[ ['no-nested-ternary', 'error', NESTED_TERNARY], ['no-else-return', 'error', ELSE_RETURN], ['prefer-const', 'error', PREFER_CONST], ['@typescript-eslint/no-non-null-assertion', 'error', NON_NULL_ASSERTION], [ '@typescript-eslint/consistent-type-assertions', ['error', { assertionStyle: 'as', objectLiteralTypeAssertions: 'never' }], OBJECT_LITERAL_ASSERTION, ], ['@typescript-eslint/consistent-type-imports', 'error', TYPE_IMPORTS], ].map(([rule, setting, ignores]) => ({ files: ['api/src/**/*.ts', 'client/src/**/*.{ts,tsx}'], ignores, rules: { [rule]: setting }, })), { files: ['api/src/**/*.ts', 'client/src/**/*.{ts,tsx}'], rules: { eqeqeq: ['error', 'smart'], 'max-depth': ['warn', 4] }, }, // Type-aware (api/src only: the client has no typed linting yet). No // floating promises (a fire-and-forget call says so with `void`), no promise // where a void callback is expected (Express handlers excepted), and five // more that skip their own list of files from before the rule // (eslint-rules/typed-lint-todo.mjs). { files: ['api/src/**/*.ts'], rules: { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': [ 'error', { checksVoidReturn: { arguments: false } }, ], }, }, ...[ ['@typescript-eslint/switch-exhaustiveness-check', SWITCH_EXHAUSTIVE], ['@typescript-eslint/no-unnecessary-type-assertion', UNNECESSARY_ASSERTION], ['@typescript-eslint/no-unnecessary-condition', UNNECESSARY_CONDITION], ['@typescript-eslint/prefer-nullish-coalescing', NULLISH_COALESCING], ['@typescript-eslint/prefer-optional-chain', OPTIONAL_CHAIN], ].map(([rule, ignores]) => ({ files: ['api/src/**/*.ts'], ignores, rules: { [rule]: 'error' } })), // Ignore patterns { ignores: [ 'node_modules/**', 'dist/**', 'public/**', 'api/dist/**', 'api/public/**', 'client/dist/**', 'playwright-report/**', 'logs/**', 'site/**', // Generated documentation site 'data/**', '.venv/**', // (legacy) Python virtual environment at repo root 'docs/.venv/**', // Python virtual environment for docs '*.js', '*.mjs', 'vite.config.ts', 'client/vite.config.ts', 'eslint.config.mjs', 'client/src/brackets-viewer/**', // Vendored code - ignore linting errors // Standalone example projects: their own package.json, lockfile and // tsconfig, installed separately and typechecked on their own in CI. // Linting them from here would apply this project's type-aware rules to // a tsconfig they are not part of. 'examples/**', // The MCP server: its own package, typechecked on its own in CI. 'mcp/**', '.pnp.cjs', // Yarn PnP file ], }, ];