Files
cvat-ai--cvat/eslint.config.mjs
2026-07-13 13:32:23 +08:00

439 lines
16 KiB
JavaScript

// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { fixupPluginRules } from '@eslint/compat';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import stylisticPlugin from '@stylistic/eslint-plugin';
import tseslintPlugin from '@typescript-eslint/eslint-plugin';
import cypressPlugin from 'eslint-plugin-cypress';
import globals from 'globals';
import importPlugin from 'eslint-plugin-import';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import noUnsanitizedPlugin from 'eslint-plugin-no-unsanitized';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import securityPlugin from 'eslint-plugin-security';
import chaiFriendlyPlugin from 'eslint-plugin-chai-friendly';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
// Convert a legacy .eslintrc-style object to flat config entries and pin it to
// the files that used to be linted from that package directory.
function scopedConfig(config, files) {
return compat.config(config).map((entry) => ({
...entry,
// FlatCompat resolves plugin names itself, but some legacy configs pull
// in plugin objects that need the manually imported/fixed versions below.
...(entry.plugins ? {
plugins: Object.fromEntries(
Object.entries(entry.plugins).map(([name, plugin]) => [name, sharedCompatPlugins[name] || plugin]),
),
} : {}),
...(entry.ignores ? {} : { files }),
}));
}
// Some legacy shareable configs still reference rules or plugin presets that do
// not load cleanly in the flat-config setup. Their supported rule sets are added
// explicitly later in this file.
function withoutUnsupportedExtends(config) {
return {
...config,
extends: (config.extends || []).filter((extend) => (
extend !== 'plugin:security/recommended' &&
extend !== 'plugin:no-unsanitized/DOM' &&
extend !== 'airbnb-typescript' &&
extend !== 'airbnb-typescript/base'
)),
rules: {
...Object.fromEntries(
Object.entries(config.rules || {}).filter(([ruleName]) => (
!ruleName.startsWith('security/')
)),
),
indent: 'off', // airbnb's legacy indent
},
};
}
// CVAT frontend packages run in browser code, Node-based tooling, and workers,
// so the shared source config starts from both browser and Node globals.
const browserAndNodeGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
AbortController: 'readonly',
WorkerGlobalScope: 'readonly',
};
// Shared plugin instances keep FlatCompat output and handwritten flat entries
// using the same plugin objects.
const sharedCompatPlugins = {
'@stylistic': stylisticPlugin,
'@typescript-eslint': tseslintPlugin,
cypress: cypressPlugin,
import: importPlugin,
'jsx-a11y': jsxA11yPlugin,
'no-unsanitized': noUnsanitizedPlugin,
react: fixupPluginRules(reactPlugin),
'react-hooks': reactHooksPlugin,
security: securityPlugin,
'chai-friendly': chaiFriendlyPlugin,
};
// eslint-plugin-react's jsx-filename-extension rule is not available in the
// shape required here, so the flat config carries a tiny local replacement.
const localReactPlugin = {
rules: {
'jsx-filename-extension': {
meta: {
type: 'problem',
docs: {
description: 'Restrict file extensions that may contain JSX',
},
schema: [{
type: 'object',
properties: {
extensions: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
}],
messages: {
unexpectedExtension: 'JSX is only allowed in files with extensions: {{ allowed }}. Current file extension: {{ ext }}.',
},
},
create(context) {
const [{ extensions = ['.tsx'] } = {}] = context.options;
let firstJSXNode = null;
return {
JSXElement(node) {
firstJSXNode ??= node;
},
JSXFragment(node) {
firstJSXNode ??= node;
},
'Program:exit'() {
if (!firstJSXNode) {
return;
}
const currentExtension = extensions.find((extension) => context.filename.endsWith(extension));
if (currentExtension) {
return;
}
context.report({
node: firstJSXNode,
messageId: 'unexpectedExtension',
data: {
allowed: extensions.join(', '),
ext: extname(context.filename) || '(no extension)',
},
});
},
};
},
},
},
};
const disabledReactRules = Object.fromEntries(
Object.keys(reactPlugin.rules).map((ruleName) => [`react/${ruleName}`, 'off']),
);
// First-party application code is TypeScript today. Vendored JS and generated
// browser assets are excluded in the ignore block instead of being lint targets.
const sourceFiles = [
'cvat-data/**/*.ts',
'cvat-core/**/*.ts',
'cvat-canvas/**/*.ts',
'cvat-canvas3d/**/*.ts',
'cvat-ui/**/*.{ts,tsx}',
];
// Cypress remains JavaScript, including the root Cypress config files.
const cypressTestFiles = [
'tests/cypress/**/*.js',
'tests/*cypress*.config.js',
];
const cypressPluginFiles = [
'tests/cypress/plugins/**/*.js',
];
const cypressGlobals = {
Cypress: 'readonly',
cy: 'readonly',
expect: 'readonly',
assert: 'readonly',
before: 'readonly',
after: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
context: 'readonly',
describe: 'readonly',
it: 'readonly',
};
const testsGlobalConfig = {
camelcase: ['error', { properties: 'never', ignoreDestructuring: false, ignoreImports: false }],
'no-continue': 0,
'no-console': 0,
'no-restricted-syntax': [
'error',
{
selector: 'VariableDeclarator > Identifier[name=/[a-z]ID/]',
message: 'Use "Id" instead of "ID" in test variable names.',
},
{
selector: ':matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression) > Identifier[name=/[a-z]ID/]',
message: 'Use "Id" instead of "ID" in test function and parameter names.',
},
{
selector: ':matches(ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier)[local.name=/[a-z]ID/]',
message: 'Use "Id" instead of "ID" in imported local names.',
},
],
'func-names': 0, // TODO: remove this, all procedures should be named
quotes: ['error', 'single', { avoidEscape: true }],
'no-underscore-dangle': ['error', { allowAfterThis: true }],
'import/order': ['error', { groups: ['builtin', 'external', 'internal'] }],
'function-paren-newline': 0,
};
const testsNodeAndCypressGlobals = {
...browserAndNodeGlobals,
...cypressGlobals,
};
const rootConfig = withoutUnsupportedExtends(require('./.eslintrc.cjs'));
const dataConfig = require('./cvat-data/.eslintrc.cjs');
const coreConfig = require('./cvat-core/.eslintrc.cjs');
const canvasConfig = require('./cvat-canvas/.eslintrc.cjs');
const canvas3dConfig = require('./cvat-canvas3d/.eslintrc.cjs');
const uiConfig = withoutUnsupportedExtends(require('./cvat-ui/.eslintrc.cjs'));
const testsConfig = withoutUnsupportedExtends(require('./tests/.eslintrc.js'));
const cypressBaseConfig = {
...testsConfig,
rules: {
...(testsConfig.rules || {}),
...testsGlobalConfig,
},
};
// Flat config is ordered. The entries below start with global ignores and broad
// shared rules, then layer package, TypeScript, Cypress, and mutation-specific
// overrides on top.
export default [
{
ignores: [
// Repository-wide generated, vendored, and hidden paths.
'.*/**',
'**/node_modules/**',
'**/dist/**',
'**/.eslintrc.js',
'**/.eslintrc.cjs',
'**/lint-staged.config.js',
'**/webpack.config.{js,cjs}',
'3rdparty/**',
'data/**',
'datumaro/**',
'keys/**',
'logs/**',
'static/**',
'templates/**',
'site/**',
// Package-specific generated or vendored files that are still
// matched by broad JavaScript globs.
'cvat-ui/exec-scripts-webpack-plugin.cjs',
'cvat-ui/src/assets/opencv*.js',
'cvat-core/tests/**/*.cjs',
'cvat-data/src/ts/3rdparty/**',
],
},
{
// Security and DOM-sanitization plugins are added here because their
// legacy shareable configs are filtered out before FlatCompat runs.
files: [
...sourceFiles,
...cypressTestFiles,
],
settings: {
react: {
version: '18.2.0',
},
},
plugins: {
'@stylistic': stylisticPlugin,
security: securityPlugin,
'no-unsanitized': noUnsanitizedPlugin,
},
rules: {
...securityPlugin.configs['recommended-legacy'].rules,
...noUnsanitizedPlugin.configs['recommended-legacy'].rules,
'no-constant-condition': ['error', { checkLoops: 'allExceptWhileTrue' }],
'security/detect-object-injection': 'off'
},
},
// Legacy package configs are still the source of truth; FlatCompat scopes
// each one to the package files that used to discover it via cwd.
...scopedConfig(rootConfig, sourceFiles),
...scopedConfig(dataConfig, ['cvat-data/**/*.ts']),
...scopedConfig(coreConfig, ['cvat-core/**/*.ts']),
...scopedConfig(canvasConfig, ['cvat-canvas/**/*.ts']),
...scopedConfig(canvas3dConfig, ['cvat-canvas3d/**/*.ts']),
...scopedConfig(uiConfig, ['cvat-ui/**/*.{ts,tsx}']),
{
// The local JSX-extension rule replaces react/jsx-filename-extension
// after the upstream React rule set is disabled below.
files: sourceFiles,
settings: {
react: {
version: '18.2.0',
},
},
plugins: {
'local-react': localReactPlugin,
},
rules: {
...disabledReactRules,
'local-react/jsx-filename-extension': ['error', { extensions: ['.tsx'] }],
},
},
{
// TypeScript-only relaxations and replacements for rules that conflict
// with @typescript-eslint or older CVAT generic patterns.
files: sourceFiles,
plugins: {
react: fixupPluginRules(reactPlugin),
},
languageOptions: {
globals: {
JSX: 'readonly',
React: 'readonly',
},
},
rules: {
camelcase: 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
'@typescript-eslint/default-param-last': 'off',
'@typescript-eslint/no-unsafe-function-type': 'error',
'lines-between-class-members': 'off',
'@typescript-eslint/lines-between-class-members': 'off',
'default-param-last': 'off',
// Relax the object type restriction - use Record<string, unknown> or explicit types where reasonable
'@typescript-eslint/no-restricted-types': 'off',
// Allow {} in generics (e.g., React.PureComponent<{}, State>)
'@typescript-eslint/no-empty-object-type': ['error', { allowObjectTypes: 'always' }],
},
},
// Tests keep a dedicated legacy config, then receive Cypress globals and
// plugin rules from the following flat entry.
...scopedConfig(cypressBaseConfig, cypressTestFiles),
{
files: cypressTestFiles,
plugins: {
import: importPlugin,
cypress: cypressPlugin,
security: securityPlugin,
'no-unsanitized': noUnsanitizedPlugin,
'chai-friendly': chaiFriendlyPlugin,
},
languageOptions: {
globals: testsNodeAndCypressGlobals,
sourceType: 'module',
ecmaVersion: 'latest',
},
rules: {
...cypressPlugin.configs.recommended.rules,
...securityPlugin.configs['recommended-legacy'].rules,
...noUnsanitizedPlugin.configs['recommended-legacy'].rules,
'import/no-unresolved': 'off',
'import/extensions': 'off',
'no-prototype-builtins': 'off',
'no-underscore-dangle': 'off',
'cypress/no-unnecessary-waiting': 'off',
'no-unused-expressions': 0,
'chai-friendly/no-unused-expressions': 'error',
'security/detect-object-injection': 'off',
},
},
{
// Cypress plugin files execute in Node but are located under the
// Cypress tree, so they override only the environment-specific pieces.
files: cypressPluginFiles,
plugins: {
import: importPlugin,
security: securityPlugin,
'no-unsanitized': noUnsanitizedPlugin,
},
languageOptions: {
globals: browserAndNodeGlobals,
sourceType: 'module',
ecmaVersion: 'latest',
},
rules: {
...securityPlugin.configs['recommended-legacy'].rules,
...noUnsanitizedPlugin.configs['recommended-legacy'].rules,
'import/no-unresolved': 'off',
'import/extensions': 'off',
'no-prototype-builtins': 'off',
'no-underscore-dangle': 'off',
},
},
{
// Keep the stricter parameter-mutation rule centralized so package and
// Cypress layers inherit the same allow-list.
files: [
...sourceFiles,
...cypressTestFiles,
],
rules: {
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: [
'_label',
'acc',
'accumulator',
'camera',
'collectionObject',
'context',
'el',
'element',
'image',
'model',
'object',
'plugin',
'shape',
'win',
],
}],
},
},
];