enso/eslint.config.mjs
2024-11-21 10:52:20 +00:00

571 lines
22 KiB
JavaScript

/** @file ESLint configuration file. */
/**
* NOTE: The "Experimental: Use Flat Config" option must be enabled.
* Flat config is still not quite mature, so is disabled by default.
*/
import * as path from 'node:path'
import * as url from 'node:url'
// The preferred syntax is `import * as name`, however these modules do not support it.
// This is specialcased in other files, but these modules shouldn't be used in other files anyway.
import eslintJs from '@eslint/js'
import tsEslint from '@typescript-eslint/eslint-plugin'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import jsdoc from 'eslint-plugin-jsdoc'
import react from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import reactHooks from 'eslint-plugin-react-hooks'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
// =================
// === Constants ===
// =================
const DEBUG_STATEMENTS_MESSAGE = 'Avoid leaving debugging statements when committing code'
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
const NAME = 'enso'
/**
* An explicit whitelist of CommonJS modules, which do not support namespace imports.
* Many of these have incorrect types, so no type error may not mean they support ESM,
* and conversely type errors may not mean they don't support ESM -
* but we add those to the whitelist anyway otherwise we get type errors.
* In particular, `string-length` supports ESM but its type definitions don't.
* `yargs` is a modules we explicitly want the default imports of.
* `node:process` is here because `process.on` does not exist on the namespace import.
*/
const DEFAULT_IMPORT_ONLY_MODULES =
'@vitejs\\u002Fplugin-react|node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|tiny-invariant|clsx|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|@modyfi\\u002Fvite-plugin-yaml|build-info|is-network-error|validator.+|.*[.]json$'
const RELATIVE_MODULES =
'projectManager|server|configParser|authentication|config|debug|detect|fileAssociations|index|ipc|log|naming|paths|preload|projectManagement|security|urlAssociations|contentConfig|desktopEnvironment|#\\u002F.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}`
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/'
const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+'
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
// =======================================
// === Restricted syntactic constructs ===
// =======================================
// Extracted to a variable because it needs to be used twice:
// - once as-is for `.d.ts`
// - once explicitly disallowing `declare`s in regular `.ts`.
const RESTRICTED_SYNTAXES = [
{
selector: `ImportDeclaration[source.value=/^(?!(${ALLOWED_DEFAULT_IMPORT_MODULES})$)[^.]/] > ImportDefaultSpecifier`,
message:
'No default imports from modules. Add to `DEFAULT_IMPORT_ONLY_MODULES` in `eslint.config.js` if the module only has a default export.',
},
{
selector: `ImportDeclaration[source.value=/^(?:${DEFAULT_IMPORT_ONLY_MODULES})$/] > ImportNamespaceSpecifier`,
message: 'No namespace imports from modules that only have a default import',
},
{
selector: `ImportDeclaration[source.value=/\\.(?:json|yaml|yml)$/] > ImportDefaultSpecifier[local.name=${NOT_CONSTANT_CASE}]`,
message: 'Use `CONSTANT_CASE` for imports from data files',
},
{
selector: `ImportDeclaration[source.value=/\\.json$/]:not(:has(ImportAttribute[key.name=type][value.value=json]))`,
message: "JSON imports must be { type: 'json' }",
},
{
selector: `ImportDeclaration[source.value=/\\.(?:yaml|yml)$/]:not(:has(ImportAttribute[key.name=type][value.value=yaml]))`,
message: "YAML imports must be { type: 'yaml' }",
},
{
selector: `ImportDeclaration[source.value=/\\.(?:json|yaml|yml)$/] > ImportNamespaceSpecifier`,
message: 'Use default imports for imports from data files',
},
{
selector: `ImportNamespaceSpecifier > Identifier[name=${NOT_CAMEL_CASE}]`,
message: 'Use `camelCase` for imports',
},
{
selector: `:matches(ImportDefaultSpecifier[local.name=/^${NAME}/i], ImportNamespaceSpecifier > Identifier[name=/^${NAME}/i])`,
message: `Don't prefix modules with \`${NAME}\``,
},
{
selector: 'ForOfStatement > .left[kind=let]',
message: 'Use `for (const x of xs)`, not `for (let x of xs)`',
},
{
selector: 'TSTypeAliasDeclaration > :matches(TSLiteralType)',
message: 'No aliases to literal types',
},
{
selector:
'TSTypeAliasDeclaration > :matches(TSBooleanKeyword, TSBigintKeyword, TSNullKeyword, TSNumberKeyword, TSObjectKeyword, TSStringKeyword, TSSymbolKeyword, TSUndefinedKeyword, TSUnknownKeyword, TSVoidKeyword)',
message:
'No aliases to primitives - consider using brands instead: `string & { _brand: "BrandName"; }`',
},
{
// Matches non-functions.
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches([init.callee.object.name=React][init.callee.property.name=forwardRef], :has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`,
message: 'Use `CONSTANT_CASE` for top-level constants that are not functions',
},
{
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression`,
message: 'Use `function foo() {}` instead of `const foo = () => {}`',
},
{
selector: `ClassBody > PropertyDefinition > ArrowFunctionExpression`,
message: 'Use `foo() {}` instead of `foo = () => {}`',
},
{
// This lint intentionally excludes classes and readonly arrays.
selector: 'TSInterfaceBody:has(TSPropertySignature[readonly=false])',
message: 'Add `readonly` modifier to all interface properties',
},
{
selector: `TSAsExpression:not(:has(TSTypeReference > Identifier[name=const]))`,
message: 'Avoid `as T`. Consider using a type annotation instead.',
},
{
selector: 'ExportNamedDeclaration > VariableDeclaration[kind=let]',
message: 'Use `export const` instead of `export let`',
},
{
selector: `Program > VariableDeclaration[kind=let] > * > ObjectExpression:has(Property > ${STRING_LITERAL}.value):not(:has(Property > .value:not(${STRING_LITERAL})))`,
message:
'Use `const` instead of `let` for top-level object literals only containing string literals',
},
{
selector:
'ImportDeclaration[source.value=/^(?:assert|async_hooks|buffer|child_process|cluster|console|constants|crypto|dgram|diagnostics_channel|dns|domain|events|fs|fs\\u002Fpromises|http|http2|https|inspector|module|net|os|path|perf_hooks|process|punycode|querystring|readline|repl|stream|string_decoder|timers|tls|trace_events|tty|url|util|v8|vm|wasi|worker_threads|zlib)$/]',
message: 'Use `node:` prefix to import builtin node modules',
},
{
selector: 'TSEnumDeclaration:not(:has(TSEnumMember))',
message: 'Enums must not be empty',
},
{
selector: 'TSAsExpression:has(TSUnknownKeyword, TSNeverKeyword, TSAnyKeyword) > TSAsExpression',
message: 'Use type assertions to specific types instead of `unknown`, `any` or `never`',
},
{
selector: ':matches(MethodDeclaration, FunctionDeclaration) FunctionDeclaration',
message: 'Use arrow functions for nested functions',
},
{
selector: 'IfStatement > ExpressionStatement',
message: 'Wrap `if` branches in `{}`',
},
{
selector: ':matches(ForStatement[test=null], ForStatement[test.value=true])',
message: 'Use `while (true)` instead of `for (;;)`',
},
{
selector: `:matches(\
JSXAttribute[name.name=/^(?:alt|error|label|placeholder|text|title|actionButtonLabel|actionText|aria-label)$/][value.raw=/^'|^"|^\`/], \
JSXText[value=/\\S/], \
JSXAttribute[name.name=/^(?:alt|error|label|placeholder|text|title|actionButtonLabel|actionText|aria-label)$/] ConditionalExpression:matches(\
[consequent.raw=/^'|^"|^\`/], \
[alternate.raw=/^'|^"|^\`/]\
)\
)`,
message: 'Use a `getText()` from `useText` instead of a literal string',
},
{
selector: `JSXAttribute[name.name=/^(?:className)$/] TemplateLiteral`,
message:
'Use `tv` from `#/utilities/tailwindVariants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
},
{
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
message: 'Use `Button` or `UnstyledButton` instead of `button`',
},
{
selector: 'JSXOpeningElement[name.name=/^h[123456]$/] > JSXIdentifier',
message: 'Use `aria.Heading` instead of `h1`-`h6`',
},
]
// ============================
// === ESLint configuration ===
// ============================
export default [
{
// Playwright build cache and Vite build directory.
ignores: [
'**/.cache/**',
'**/playwright-report',
'**/dist',
'**/mockDist',
'**/build.mjs',
'**/*.timestamp-*.mjs',
'**/node_modules',
'**/generated',
'app/rust-ffi/pkg/',
],
},
eslintJs.configs.recommended,
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
{
// files: ['{**,src}/*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
languageOptions: {
parserOptions: {
tsconfigRootDir: DIR_NAME,
ecmaVersion: 'latest',
extraFileExtensions: ['.vue'],
projectService: {
allowDefaultProject: [
'app/ydoc-server/vitest.config.ts',
'app/ydoc-shared/vitest.config.ts',
],
},
},
},
rules: {
camelcase: ['warn', { ignoreImports: true, ignoreDestructuring: true }],
'no-inner-declarations': 'off',
'vue/attribute-hyphenation': ['error', 'never'],
'vue/v-on-event-hyphenation': ['error', 'never'],
'vue/singleline-html-element-content-newline': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-namespace': 'off',
// Empty interfaces have valid uses; e.g. although an empty interface extending a class is semantically equivalent
// to a type alias, it is not resolved by IDEs to the base type (which may be internal).
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'always' }],
'no-unused-labels': 'off',
// Taken care of by prettier
'vue/max-attributes-per-line': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
// TODO[ao]; off temporarily
'@typescript-eslint/no-explicit-any': 'off',
'vue/html-self-closing': 'off',
},
},
{
files: ['app/gui/templates/*.vue'],
rules: {
'vue/multi-word-component-names': 0,
},
},
// JsDoc lints for typescript - the recommended set with some modifications.
{
ignores: ['**/*.js'],
...jsdoc.configs['flat/recommended-typescript'],
rules: {
...jsdoc.configs['flat/recommended-typescript'].rules,
'jsdoc/check-param-names': [
'warn',
{ checkDestructured: false, disableMissingParamChecks: true },
],
'jsdoc/require-jsdoc': [
'warn',
{
publicOnly: true,
require: {
FunctionDeclaration: true,
MethodDefinition: true,
ClassDeclaration: true,
ArrowFunctionExpression: false,
FunctionExpression: true,
},
},
],
'jsdoc/require-param': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-yields': 'off',
},
},
// === Dashboard Rules ===
{
files: [
'app/gui/src/dashboard/**/*.ts',
'app/gui/src/dashboard/**/*.mts',
'app/gui/src/dashboard/**/*.cts',
'app/gui/src/dashboard/**/*.tsx',
'app/gui/src/dashboard/**/*.mtsx',
'app/gui/src/dashboard/**/*.ctsx',
],
settings: {
react: {
version: '18.2',
},
},
plugins: {
jsdoc: jsdoc,
'@typescript-eslint': tsEslint,
react: react,
'react-hooks': reactHooks,
'react-compiler': reactCompiler,
},
languageOptions: {
parserOptions: {
tsconfigRootDir: DIR_NAME,
projectService: true,
},
globals: {
...globals.browser,
...globals.node,
...globals.es2015,
},
},
rules: {
...tsEslint.configs['eslint-recommended']?.rules,
...tsEslint.configs.recommended?.rules,
...tsEslint.configs['recommended-requiring-type-checking']?.rules,
...tsEslint.configs.strict?.rules,
...react.configs['jsx-runtime'].rules,
eqeqeq: ['error', 'always', { null: 'never' }],
// Any extra semicolons that exist, are required by Prettier.
'no-extra-semi': 'off',
'jsdoc/require-jsdoc': [
'error',
{
require: {
FunctionDeclaration: true,
MethodDefinition: true,
ClassDeclaration: true,
ArrowFunctionExpression: false,
FunctionExpression: true,
},
// Top-level constants should require JSDoc as well,
// however it does not seem like there is a way to do this.
contexts: [
'TSInterfaceDeclaration',
'TSEnumDeclaration',
'TSTypeAliasDeclaration',
'TSMethodSignature',
],
},
],
'no-constant-condition': ['error', { checkLoops: false }],
'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES],
'no-restricted-properties': [
'error',
{ object: 'console', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugState', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugEffect', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugMemo', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugCallback', message: DEBUG_STATEMENTS_MESSAGE },
{
object: 'page',
property: 'type',
message: 'Prefer `locator.type` instead',
},
{
object: 'page',
property: 'click',
message: 'Prefer `locator.click` instead',
},
{
object: 'page',
property: 'fill',
message: 'Prefer `locator.fill` instead',
},
{
object: 'page',
property: 'locator',
message: 'Prefer `page.getBy*` instead',
},
],
'prefer-const': 'error',
'react/forbid-elements': [
'error',
{ forbid: [{ element: 'Debug', message: DEBUG_STATEMENTS_MESSAGE }] },
],
// Not relevant because TypeScript checks types.
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': ['error', { additionalHooks: 'useOnScroll' }],
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
// We use warnings instead of errors because we want to gradually migrate the codebase to the new compiler.
// see: https://github.com/reactwg/react-compiler/discussions/8
'react-compiler/react-compiler': 'warn',
// Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/member-ordering': 'error',
// Method syntax is not type-safe.
// See: https://typescript-eslint.io/rules/method-signature-style
'@typescript-eslint/method-signature-style': 'error',
'@typescript-eslint/naming-convention': [
'error',
{
selector: ['function'],
// PascalCase for React components, camelCase for all other situations
format: ['camelCase', 'PascalCase'],
},
{
selector: ['variable'],
modifiers: ['const', 'global'],
format: ['UPPER_CASE', 'camelCase', 'PascalCase'],
},
{
selector: ['variable'],
modifiers: ['const', 'exported'],
format: ['UPPER_CASE', 'camelCase', 'PascalCase'],
},
{
selector: ['variable'],
format: ['camelCase', 'PascalCase'],
},
{
selector: ['parameter', 'method'],
format: ['camelCase'],
},
{
selector: ['parameter'],
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
},
{
selector: ['property'],
format: ['camelCase'],
filter: {
regex: '^(?:data-testid)$',
match: false,
},
},
],
'@typescript-eslint/no-confusing-void-expression': 'error',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-invalid-void-type': ['error', { allowAsThisParameter: true }],
// React 17 and later supports async functions as event handlers, so we need to disable this
// rule to avoid false positives.
//
// See: https://github.com/typescript-eslint/typescript-eslint/pull/4623
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { attributes: false } },
],
'@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-unnecessary-condition': [
'error',
{ allowConstantLoopConditions: true },
],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/parameter-properties': ['error', { prefer: 'parameter-property' }],
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/require-array-sort-compare': ['error', { ignoreStringArrays: true }],
'@typescript-eslint/restrict-template-expressions': 'error',
'@typescript-eslint/sort-type-constituents': 'error',
'@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'default-param-last': 'off',
'@typescript-eslint/default-param-last': 'error',
'no-invalid-this': 'off',
'@typescript-eslint/no-invalid-this': ['error', { capIsConstructor: false }],
'jsdoc/no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [
'error',
{
ignore: [-1, 0, 1, 2, 3, 4, 5],
ignoreArrayIndexes: true,
ignoreEnums: true,
detectObjects: true,
enforceConst: true,
},
],
'no-redeclare': 'off',
// Important to warn on accidental duplicated `interface`s e.g. when writing API wrappers.
'@typescript-eslint/no-redeclare': ['error', { ignoreDeclarationMerge: false }],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': 'error',
'jsdoc/require-param-type': 'off',
'jsdoc/check-access': 'error',
'jsdoc/check-alignment': 'error',
'jsdoc/check-indentation': 'error',
'jsdoc/check-line-alignment': 'error',
'jsdoc/check-param-names': 'error',
'jsdoc/check-property-names': 'error',
'jsdoc/check-syntax': 'error',
'jsdoc/check-tag-names': 'error',
'jsdoc/check-types': 'error',
'jsdoc/check-values': 'error',
'jsdoc/empty-tags': 'error',
'jsdoc/implements-on-classes': 'error',
'jsdoc/no-bad-blocks': 'error',
'jsdoc/no-defaults': 'error',
'jsdoc/no-multi-asterisks': 'error',
'jsdoc/no-types': 'error',
'jsdoc/no-undefined-types': 'error',
'jsdoc/require-asterisk-prefix': 'error',
'jsdoc/require-description': 'error',
// This rule does not handle `# Heading`s and "etc.", "e.g.", "vs." etc.
// 'jsdoc/require-description-complete-sentence': 'error',
'jsdoc/require-file-overview': 'error',
'jsdoc/require-hyphen-before-param-description': 'error',
'jsdoc/require-param-description': 'error',
'jsdoc/require-param-name': 'error',
'jsdoc/require-property': 'error',
'jsdoc/require-property-description': 'error',
'jsdoc/require-property-name': 'error',
'jsdoc/require-property-type': 'error',
'jsdoc/require-returns-check': 'error',
'jsdoc/require-returns-description': 'error',
'jsdoc/require-throws': 'error',
'jsdoc/require-yields': 'error',
'jsdoc/require-yields-check': 'error',
'jsdoc/tag-lines': 'error',
'jsdoc/valid-types': 'error',
},
},
{
files: ['**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'],
ignores: ['**/build.mjs'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
// Parameter types must be specified using JSDoc in JS files.
'jsdoc/no-types': 'off',
},
},
{
files: [
'app/gui/src/dashboard/**/*.ts',
'app/gui/src/dashboard/**/*.mts',
'app/gui/src/dashboard/**/*.cts',
'app/gui/src/dashboard/**/*.tsx',
'app/gui/src/dashboard/**/*.mtsx',
'app/gui/src/dashboard/**/*.ctsx',
],
ignores: ['**/*.d.ts'],
rules: {
'no-restricted-syntax': [
'error',
...RESTRICTED_SYNTAXES,
{
selector: ':not(TSModuleDeclaration)[declare=true]',
message: 'No ambient declarations',
},
],
// This rule does not work with TypeScript, and TypeScript already does this.
'no-undef': 'off',
},
},
{
files: ['app/gui/src/dashboard/**/*.stories.tsx'],
rules: {
'no-restricted-syntax': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-file-overview': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
'@typescript-eslint/unbound-method': 'off',
},
},
]