Files
CherryHQ-cherry-studio/eslint.config.mjs
亢奋猫 eb2622e9af refactor(ui): remove antd dependency (#16336)
Co-authored-by: gujiaming <52187003+AtomsH4@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: kangfenmao <kangfenmao@qq.com>
Signed-off-by: gujiaming <52187003+AtomsH4@users.noreply.github.com>
2026-06-26 15:49:22 +08:00

522 lines
19 KiB
JavaScript

import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'
const LEGACY_RENDERER_CSS_VARS = [
'--color-text-1',
'--color-text-2',
'--color-text-3',
'--color-text',
'--color-text-secondary',
'--color-text-soft',
'--color-text-light',
'--color-background-soft',
'--color-background-mute',
'--color-background-opacity',
'--color-border-soft',
'--color-border-mute',
'--color-error',
'--color-link',
'--color-primary-bg',
'--color-fill-secondary',
'--color-fill-2',
'--color-bg-base',
'--color-bg-1',
'--color-code-background',
'--color-inline-code-background',
'--color-inline-code-text',
'--color-hover',
'--color-active',
'--color-frame-border',
'--color-group-background',
'--color-reference',
'--color-reference-text',
'--color-reference-background',
'--color-list-item',
'--color-list-item-hover',
'--color-highlight',
'--color-background-highlight',
'--color-background-highlight-accent',
'--navbar-background-mac',
'--navbar-background',
'--modal-background',
'--chat-background',
'--chat-background-user',
'--chat-background-assistant',
'--chat-text-user',
'--list-item-border-radius',
'--color-gray-1',
'--color-gray-2',
'--color-gray-3',
'--color-icon-white',
'--color-primary-1',
'--color-primary-6',
'--color-status-success',
'--color-status-error',
'--color-status-warning'
]
const LEGACY_RENDERER_CSS_VAR_REGEX = new RegExp(
`(${LEGACY_RENDERER_CSS_VARS.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![\\w-])`,
'g'
)
export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'],
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports,
'import-zod': importZod
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
{
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off',
'@eslint-react/web-api/no-leaked-event-listener': 'off',
'@eslint-react/web-api/no-leaked-timeout': 'off',
'@eslint-react/no-unknown-property': 'off',
'@eslint-react/no-nested-component-definitions': 'off',
'@eslint-react/dom/no-dangerously-set-innerhtml': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/no-unstable-default-props': 'off',
'@eslint-react/no-unstable-context-value': 'off',
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off',
'@eslint-react/hooks-extra/no-unnecessary-use-prefix': 'off',
'@eslint-react/no-children-to-array': 'off'
}
},
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'tests/**',
'.yarn/**',
'.gitignore',
'.conductor/**',
'scripts/cloudflare-worker.js',
'src/main/services/nutstore/sso/lib/**',
'src/renderer/ui/**',
'src/renderer/routeTree.gen.ts',
'packages/**/dist',
'v2-refactor-temp/**'
]
},
// turn off oxlint supported rules.
...oxlint.configs['flat/eslint'],
...oxlint.configs['flat/typescript'],
...oxlint.configs['flat/unicorn'],
// Custom rules should be after oxlint to overwrite
// LoggerService Custom Rules - only apply to src directory
{
files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'],
rules: {
'no-restricted-syntax': [
process.env.CI ? 'error' : 'warn',
{
selector: 'CallExpression[callee.object.name="console"]',
message:
'❗CherryStudio uses unified LoggerService: 📖 docs/en/guides/logging.md\n❗CherryStudio 使用统一的日志服务:📖 docs/zh/guides/logging.md\n\n'
}
]
}
},
// Application lifecycle - all quit-related APIs and events are managed by Application.ts
{
files: ['src/main/**/*.{ts,tsx,js,jsx}'],
ignores: [
'src/main/core/application/Application.ts',
'src/main/data/migration/**',
'src/main/**/__tests__/**',
'src/main/**/__mocks__/**',
'src/main/**/*.test.*'
],
plugins: {
lifecycle: {
rules: {
'no-direct-quit': {
meta: {
type: 'problem',
docs: {
description:
'Disallow direct use of quit-related Electron/Node.js APIs. All quit handling is centralized in Application.ts.',
recommended: true
},
messages: {
restricted:
'Quit-related APIs and events are managed by the Application lifecycle. Do not use "{{name}}" directly. See docs/en/references/lifecycle/application-overview.md'
}
},
create(context) {
const RESTRICTED_APP_METHODS = new Set(['quit', 'exit', 'relaunch'])
const RESTRICTED_APP_EVENTS = new Set(['before-quit', 'will-quit', 'window-all-closed'])
const RESTRICTED_SIGNALS = new Set(['SIGINT', 'SIGTERM'])
return {
CallExpression(node) {
const { callee } = node
if (callee.type !== 'MemberExpression') return
if (callee.object.type !== 'Identifier') return
const obj = callee.object.name
const prop = callee.property.type === 'Identifier' ? callee.property.name : null
if (!prop) return
// app.quit() / app.exit() / app.relaunch()
if (obj === 'app' && RESTRICTED_APP_METHODS.has(prop)) {
context.report({ node, messageId: 'restricted', data: { name: `app.${prop}()` } })
return
}
// app.on/once('before-quit'|'will-quit'|'window-all-closed', ...)
if (obj === 'app' && (prop === 'on' || prop === 'once')) {
const firstArg = node.arguments[0]
if (firstArg?.type === 'Literal' && RESTRICTED_APP_EVENTS.has(firstArg.value)) {
context.report({
node,
messageId: 'restricted',
data: { name: `app.${prop}('${firstArg.value}')` }
})
}
return
}
// process.on/once('SIGINT'|'SIGTERM', ...)
if (obj === 'process' && (prop === 'on' || prop === 'once')) {
const firstArg = node.arguments[0]
if (firstArg?.type === 'Literal' && RESTRICTED_SIGNALS.has(firstArg.value)) {
context.report({
node,
messageId: 'restricted',
data: { name: `process.${prop}('${firstArg.value}')` }
})
}
}
}
}
}
}
}
}
},
rules: {
'lifecycle/no-direct-quit': 'warn'
}
},
// i18n
{
files: ['**/*.{ts,tsx,js,jsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module'
},
plugins: {
i18n: {
rules: {
'no-template-in-t': {
meta: {
type: 'problem',
docs: {
description: '⚠️不建议在 t() 函数中使用模板字符串,这样会导致渲染结果不可预料',
recommended: true
},
messages: {
noTemplateInT: '⚠️不建议在 t() 函数中使用模板字符串,这样会导致渲染结果不可预料'
}
},
create(context) {
return {
CallExpression(node) {
const { callee, arguments: args } = node
const isTFunction =
(callee.type === 'Identifier' && callee.name === 't') ||
(callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
callee.property.name === 't')
if (isTFunction && args[0]?.type === 'TemplateLiteral') {
context.report({
node: args[0],
messageId: 'noTemplateInT'
})
}
}
}
}
}
}
}
},
rules: {
'i18n/no-template-in-t': 'warn'
}
},
{
// Bundle guard: the IpcApi zod schema *values* must never enter the renderer
// bundle. Renderer code may only `import type` from the schema modules.
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/renderer/**/*.test.*', 'src/renderer/**/__tests__/**', 'src/renderer/**/__mocks__/**'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@shared/ipc/schemas', '@shared/ipc/schemas/*'],
allowTypeImports: true,
message:
'Renderer may only `import type` from @shared/ipc/schemas — a value import pulls the entire zod schema set into the renderer bundle.'
}
]
}
]
}
},
{
// Boundary guard: the main process and preload must not import renderer code.
// Cross-process symbols belong in `@shared`; main-only symbols in `src/main`.
// (The relative `../../renderer/i18n` imports in src/main/utils/language.ts are
// a known remaining violation, deferred to the i18n migration PR — once that
// lands, add `**/renderer/**` to the banned group below.)
files: ['src/main/**/*.{ts,tsx,js,jsx}', 'src/preload/**/*.{ts,tsx,js,jsx}'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@renderer', '@renderer/**'],
message:
'Main/preload must not import renderer code. Use `@shared` for cross-process types, or `src/main` for main-only types. See docs/references/shared-layer-architecture.md.'
}
]
}
]
}
},
// renderer legacy css var migration warnings
{
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
ignores: [
'src/renderer/**/*.test.*',
'src/renderer/**/__tests__/**',
'src/renderer/**/__mocks__/**'
],
plugins: {
'renderer-styles': {
rules: {
'no-legacy-css-vars': {
meta: {
type: 'suggestion',
docs: {
description:
'Warn when renderer code references legacy CSS compatibility variables instead of the shared theme contract.',
recommended: true
},
messages: {
legacyVar:
'Legacy renderer CSS variable "{{variable}}" is deprecated. Prefer @cherrystudio/ui theme contract variables or Tailwind semantic utilities instead.'
}
},
create(context) {
function reportIfLegacyCssVar(node, text) {
const matches = text.matchAll(LEGACY_RENDERER_CSS_VAR_REGEX)
for (const match of matches) {
const variable = match[1]
if (!variable) continue
context.report({
node,
messageId: 'legacyVar',
data: { variable }
})
}
}
return {
Literal(node) {
if (typeof node.value !== 'string') return
reportIfLegacyCssVar(node, node.value)
},
TemplateElement(node) {
reportIfLegacyCssVar(node, node.value.raw)
},
JSXText(node) {
reportIfLegacyCssVar(node, node.value)
}
}
}
}
}
}
},
rules: {
'renderer-styles/no-legacy-css-vars': process.env.NO_LEGACY_CSS_WARN ? 'off' : 'warn'
}
},
// Schema key naming convention (cache, preferences, paths & IPC route/event keys)
// Supports both fixed keys and template keys:
// - Fixed: 'app.user.avatar', 'chat.multi_select_mode'
// - Template: 'scroll.position.${topicId}', 'entity.cache.${type}_${id}'
// Template keys must follow the same dot-separated pattern as fixed keys.
// When ${xxx} placeholders are treated as literal strings, the key must match: xxx.yyy.zzz_www
{
files: [
'src/shared/data/cache/cacheSchemas.ts',
'src/shared/data/preference/preferenceSchemas.ts',
'src/main/core/paths/pathRegistry.ts',
// IPC route/event keys — whole dir so future domains are auto-enforced (see ipc-schema-guide.md).
'src/shared/ipc/schemas/**/*.ts'
],
plugins: {
'data-schema-key': {
rules: {
'valid-key': {
meta: {
type: 'problem',
docs: {
description:
'Enforce schema key naming convention: namespace.sub.key_name (template placeholders treated as literal strings)',
recommended: true
},
messages: {
invalidKey:
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar, scroll.position.${id}). Template ${xxx} is treated as a literal string segment.',
invalidTemplateVar:
'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).'
}
},
create(context) {
/**
* Validates a schema key for correct naming convention.
*
* Both fixed keys and template keys must follow the same pattern:
* - Lowercase segments separated by dots
* - Each segment: starts with letter, contains letters/numbers/underscores
* - At least two segments (must have at least one dot)
*
* Template keys: ${xxx} placeholders are treated as literal string segments.
* Example valid: 'scroll.position.${id}', 'entity.cache.${type}_${id}'
* Example invalid: 'cache:${type}' (colon not allowed), '${id}' (no dot)
*
* @param {string} key - The schema key to validate
* @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }}
*/
function validateKey(key) {
// Check if key contains template placeholders
const hasTemplate = key.includes('${')
if (hasTemplate) {
// Validate template variable names first
const templateVarPattern = /\$\{([^}]*)\}/g
let match
while ((match = templateVarPattern.exec(key)) !== null) {
const varName = match[1]
// Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore
if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) {
return { valid: false, error: 'invalidTemplateVar' }
}
}
// Replace template placeholders with a valid segment marker
// Use 'x' as placeholder since it's a valid segment character
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x')
// Template key must follow the same pattern as fixed keys
// when ${xxx} is treated as a literal string
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(keyWithoutTemplates)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
} else {
// Fixed key validation: standard dot-separated format
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(key)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
}
}
return {
TSPropertySignature(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
const key = node.key.value
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: result.error,
data: { key }
})
}
}
},
Property(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
// Keys inside a `z.*(...)` object literal are zod data-field names
// (e.g. z.object({ 'content-type': ... })), not route/schema keys, so the
// namespace.action convention does not apply — skip them. Anchored on the
// zod namespace `z`, this covers z.object/z.strictObject/etc. while leaving
// Object.freeze(...) registries (pathRegistry.ts) still validated.
const enclosing = node.parent
if (
enclosing?.parent?.type === 'CallExpression' &&
enclosing.parent.callee?.type === 'MemberExpression' &&
enclosing.parent.callee.object?.type === 'Identifier' &&
enclosing.parent.callee.object.name === 'z'
) {
return
}
const key = node.key.value
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: result.error,
data: { key }
})
}
}
}
}
}
}
}
}
},
rules: {
'data-schema-key/valid-key': 'error'
}
}
])