mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-07 15:12:18 +08:00
The barrel/module-boundary rules (naming-conventions.md §6.4) were warn-only while the codebase was being brought into compliance. The full tree is now barrel-clean (lint reports 0 barrel findings), so promote all seven rules — no-export-star, index-no-impl, no-index-tsx, named-only, closed, no-nesting, no-bucket-root — from warn to error to lock the boundary against regressions.
909 lines
37 KiB
JavaScript
909 lines
37 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
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 { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
|
import importX from 'eslint-plugin-import-x'
|
|
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'
|
|
)
|
|
|
|
// --- renderer dependency-direction boundary gate (import-x/no-restricted-paths) ---
|
|
const RENDERER_DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
|
const PAGE_DOMAINS = fs
|
|
.readdirSync(path.join(RENDERER_DIRNAME, 'src/renderer/pages'), { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name)
|
|
|
|
// A page must not import a sibling page domain; its own subtree is allowed via `except` (resolved relative to `from`).
|
|
const pageSiblingZones = PAGE_DOMAINS.map((p) => ({
|
|
target: `src/renderer/pages/${p}`,
|
|
from: 'src/renderer/pages',
|
|
except: [`./${p}`],
|
|
message: 'A page must not import another page (cross-page coupling). renderer-architecture.md §7.'
|
|
}))
|
|
|
|
// Topic barrels under services/: a services/<topic>/ exposes exactly one curated index.ts as its sole
|
|
// external entry (renderer-architecture.md §3.1/§5). Auto-discovered from the filesystem so a new topic dir
|
|
// needs zero rule edits — mirrors pageSiblingZones above. A topic's own subtree is excluded from `target`
|
|
// (extglob negation), so internal `./sibling` imports stay legal while every outside importer is limited to
|
|
// the barrel. Applied in every renderer importer region via blocks L/P/B below.
|
|
const SERVICES_DIR = path.join(RENDERER_DIRNAME, 'src/renderer/services')
|
|
const serviceTopics = fs
|
|
.readdirSync(SERVICES_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && d.name !== '__tests__' && d.name !== '__mocks__')
|
|
.filter((d) => fs.existsSync(path.join(SERVICES_DIR, d.name, 'index.ts')))
|
|
.map((d) => d.name)
|
|
|
|
const serviceBarrelZones = serviceTopics.map((topic) => ({
|
|
target: [
|
|
`src/renderer/!(services)/**/*`, // importers outside services/ entirely
|
|
`src/renderer/services/!(${topic})/**/*`, // sibling topic dirs
|
|
`src/renderer/services/*` // flat files at the services/ root
|
|
],
|
|
from: `src/renderer/services/${topic}/**/*`,
|
|
except: ['**/index.ts'], // the barrel itself stays importable
|
|
message: `services/${topic}/ is a topic barrel — import @renderer/services/${topic} (its index.ts), not its internals. renderer-architecture.md §3.1/§5.`
|
|
}))
|
|
|
|
// Each block's `files` is scoped so the three no-restricted-paths instances (L/P/B) never both apply to one
|
|
// file — flat config merges rules by key (last-wins), which would otherwise drop one block silently.
|
|
const SHARED_BUCKET_FILES = [
|
|
'src/renderer/components/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/hooks/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/services/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/utils/**/*.{ts,tsx,js,jsx}'
|
|
]
|
|
const PAGE_FILES = ['src/renderer/pages/**/*.{ts,tsx,js,jsx}']
|
|
const RENDERER_IGNORES = ['src/renderer/**/*.test.*', 'src/renderer/**/__tests__/**', 'src/renderer/**/__mocks__/**']
|
|
const boundarySettings = {
|
|
'import-x/resolver-next': [
|
|
createTypeScriptImportResolver({ project: path.join(RENDERER_DIRNAME, 'tsconfig.web.json'), alwaysTryTypes: true })
|
|
]
|
|
}
|
|
// Two independent gates: block1 (layer edges) is enforced as error — Stage 1 cleared it; block2 (sibling pages) stays warn until features-ization.
|
|
const RENDERER_BOUNDARY = 'error'
|
|
const PAGE_SIBLING = process.env.RENDERER_PAGE_SIBLING_ERROR ? 'error' : 'warn'
|
|
|
|
// --- barrel / module-boundary rules (naming-conventions.md §6.4) ---
|
|
// An inline custom plugin (like the `lifecycle` plugin below), not no-restricted-paths:
|
|
// full-src barrel closure needs a private boundary per directory at arbitrary depth, which
|
|
// no-restricted-paths cannot express without per-level target globs. Barrel discovery reuses
|
|
// the same "pure re-export index.ts" classifier the audit validated. All rules are `warn`.
|
|
const SRC_DIR = path.join(RENDERER_DIRNAME, 'src')
|
|
const BARREL_BUCKET_ROOT_RE = /[\\/]src[\\/](?:main|renderer|shared)[\\/](?:types|utils|services)[\\/]index\.tsx?$/
|
|
|
|
const stripCodeComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
const isPureReexportIndex = (content) => {
|
|
if (!/\bexport\b[^;]*?\bfrom[ \t]*['"]/.test(content)) return false
|
|
const rest = stripCodeComments(content)
|
|
.replace(/(?:^|\n)[ \t]*(?:import|export)\b[^;]*?from[ \t]*['"][^'"]+['"];?/g, '\n')
|
|
.replace(/(?:^|\n)[ \t]*import[ \t]*['"][^'"]+['"];?/g, '\n')
|
|
return !/\bexport\b/.test(rest)
|
|
}
|
|
const collectIndexTs = (dir, out = []) => {
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return out
|
|
}
|
|
for (const e of entries) {
|
|
if (e.name === 'node_modules' || e.name === '__tests__' || e.name === '__mocks__') continue
|
|
const p = path.join(dir, e.name)
|
|
if (e.isDirectory()) collectIndexTs(p, out)
|
|
else if (e.name === 'index.ts') out.push(p)
|
|
}
|
|
return out
|
|
}
|
|
const BARREL_DIRS = new Set()
|
|
for (const idx of collectIndexTs(SRC_DIR)) {
|
|
try {
|
|
if (isPureReexportIndex(fs.readFileSync(idx, 'utf8'))) BARREL_DIRS.add(path.dirname(idx))
|
|
} catch {}
|
|
}
|
|
const BARREL_DIRS_DEEPEST_FIRST = [...BARREL_DIRS].sort((a, b) => b.length - a.length)
|
|
const innermostBarrelDir = (file) => {
|
|
for (const d of BARREL_DIRS_DEEPEST_FIRST) if (file === d || file.startsWith(d + path.sep)) return d
|
|
return null
|
|
}
|
|
// The boundary a reference crosses is the OUTERMOST barrel dir containing the target but not
|
|
// the importer — the innermost would let `A/B` (an inner barrel's index) bypass A's door when
|
|
// barrels are nested.
|
|
const BARREL_DIRS_SHALLOWEST_FIRST = [...BARREL_DIRS_DEEPEST_FIRST].reverse()
|
|
const outermostCrossedBarrelDir = (tgt, from) => {
|
|
for (const d of BARREL_DIRS_SHALLOWEST_FIRST)
|
|
if (tgt.startsWith(d + path.sep) && from !== d && !from.startsWith(d + path.sep)) return d
|
|
return null
|
|
}
|
|
const BARREL_RESOLVE_CACHE = new Map()
|
|
// Unresolved specs are skipped — misses only, never false positives. Deliberately unresolved:
|
|
// `@logger` (single-file target, cannot hide a deep import), `@application` (bare-only usage,
|
|
// zero `@application/` deep paths in src), `@test-helpers`/`@test-mocks` (tests are exempt),
|
|
// `@cherrystudio/*`/`@mcp-trace/*` (packages/*, outside src).
|
|
const resolveBarrelSpec = (spec, fromFile) => {
|
|
const key = `${fromFile}\0${spec}`
|
|
if (BARREL_RESOLVE_CACHE.has(key)) return BARREL_RESOLVE_CACHE.get(key)
|
|
const proc = fromFile.includes(`${path.sep}src${path.sep}main${path.sep}`) ? 'main' : 'renderer'
|
|
let base = null
|
|
if (spec.startsWith('./') || spec.startsWith('../')) base = path.resolve(path.dirname(fromFile), spec)
|
|
else if (spec.startsWith('@renderer/')) base = path.join(SRC_DIR, 'renderer', spec.slice(10))
|
|
else if (spec.startsWith('@main/')) base = path.join(SRC_DIR, 'main', spec.slice(6))
|
|
else if (spec.startsWith('@shared/')) base = path.join(SRC_DIR, 'shared', spec.slice(8))
|
|
else if (spec.startsWith('@data/')) base = path.join(SRC_DIR, proc === 'main' ? 'main' : 'renderer', 'data', spec.slice(6))
|
|
let resolved = null
|
|
if (base) {
|
|
for (const c of [`${base}.ts`, `${base}.tsx`, path.join(base, 'index.ts'), path.join(base, 'index.tsx'), base]) {
|
|
try {
|
|
if (fs.statSync(c).isFile()) {
|
|
resolved = c
|
|
break
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
BARREL_RESOLVE_CACHE.set(key, resolved)
|
|
return resolved
|
|
}
|
|
const barrelFilename = (ctx) => ctx.filename ?? ctx.getFilename()
|
|
|
|
const barrelPlugin = {
|
|
rules: {
|
|
// 1a — no `export *`; barrels use explicit named re-exports.
|
|
'no-export-star': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
return {
|
|
ExportAllDeclaration(node) {
|
|
if (node.source) ctx.report({ node, message: 'No `export *` — use explicit named re-exports (naming-conventions.md §6.4 rule 1).' })
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 1b — an index.ts barrel is pure re-export: no default impl, no local declarations/bindings,
|
|
// no side-effect imports, no top-level logic.
|
|
'index-no-impl': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
const f = barrelFilename(ctx)
|
|
if (!/[\\/]index\.ts$/.test(f)) return {}
|
|
return {
|
|
Program(node) {
|
|
const stmt = node.body.find((s) => !/^(?:Import|Export)/.test(s.type))
|
|
if (stmt) ctx.report({ node: stmt, message: 'A barrel is pure re-export — no top-level statements; move logic to a named file (naming-conventions.md §6.4 rule 1).' })
|
|
},
|
|
ImportDeclaration(node) {
|
|
if (!node.specifiers.length)
|
|
ctx.report({ node, message: 'A barrel is pure re-export — no side-effect imports; registration belongs in a named module (naming-conventions.md §6.4 rule 1).' })
|
|
},
|
|
ExportDefaultDeclaration(node) {
|
|
ctx.report({ node, message: 'A barrel is pure re-export — no `export default` implementation; use a named file (naming-conventions.md §6.4).' })
|
|
},
|
|
ExportNamedDeclaration(node) {
|
|
if (node.declaration)
|
|
ctx.report({ node, message: 'A barrel is pure re-export — no local declarations; move implementation to a named file (naming-conventions.md §6.4).' })
|
|
else if (!node.source && node.specifiers.length)
|
|
ctx.report({ node, message: 'A barrel re-exports from other modules — it must not export local bindings (naming-conventions.md §6.4).' })
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 1c — no `index.tsx` anywhere: a barrel is `index.ts` (no JSX), a component uses a named
|
|
// file, and a TanStack index route uses the flat dot form (`<segment>.index.tsx`).
|
|
'no-index-tsx': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
const f = barrelFilename(ctx)
|
|
if (!/[\\/]index\.tsx$/.test(f)) return {}
|
|
return {
|
|
Program(node) {
|
|
ctx.report({ node, message: 'No `index.tsx` — a barrel is `index.ts` (re-export has no JSX); a component uses a named file; a TanStack index route uses the flat dot form `<segment>.index.tsx` (naming-conventions.md §6.4).' })
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 1d — a barrel exposes named exports, not a forwarded bare default.
|
|
'named-only': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
const f = barrelFilename(ctx)
|
|
if (!/[\\/]index\.tsx?$/.test(f)) return {}
|
|
return {
|
|
ExportNamedDeclaration(node) {
|
|
if (!node.source) return
|
|
for (const s of node.specifiers)
|
|
if (s.exported && s.exported.name === 'default')
|
|
ctx.report({ node: s, message: 'A barrel exposes named exports — name it (`export { default as Foo } from`), do not forward a bare default (naming-conventions.md §6.4 rule 1).' })
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 2 — closed boundary: outside code must import a barrel's index, never its internals.
|
|
'closed': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
const f = barrelFilename(ctx)
|
|
const check = (node, spec) => {
|
|
const tgt = resolveBarrelSpec(spec, f)
|
|
if (!tgt) return
|
|
const d = outermostCrossedBarrelDir(tgt, f)
|
|
if (!d || tgt === path.join(d, 'index.ts')) return
|
|
ctx.report({ node, message: `Deep import into barrel \`${path.relative(SRC_DIR, d)}\` — import its index, not its internals (naming-conventions.md §6.4 rule 2).` })
|
|
}
|
|
return {
|
|
ImportDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
},
|
|
ExportNamedDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
},
|
|
ExportAllDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
},
|
|
ImportExpression(node) {
|
|
if (node.source && node.source.type === 'Literal') check(node, node.source.value)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 3a — no nesting: a barrel index must not re-export another barrel.
|
|
'no-nesting': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
const f = barrelFilename(ctx)
|
|
if (!/[\\/]index\.ts$/.test(f) || !BARREL_DIRS.has(path.dirname(f))) return {}
|
|
const db = path.dirname(f)
|
|
const check = (node, spec) => {
|
|
const tgt = resolveBarrelSpec(spec, f)
|
|
if (!tgt) return
|
|
const d2 = innermostBarrelDir(tgt)
|
|
if (!d2 || d2 === db) return
|
|
ctx.report({ node, message: `A barrel must not re-export another barrel \`${path.relative(SRC_DIR, d2)}\` — let each unit own its door (naming-conventions.md §6.4 rule 3).` })
|
|
}
|
|
return {
|
|
ExportNamedDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
},
|
|
ExportAllDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
},
|
|
ImportDeclaration(node) {
|
|
if (node.source) check(node, node.source.value)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 3b — bucket roots (types/utils/services) carry no barrel.
|
|
'no-bucket-root': {
|
|
meta: { type: 'problem', schema: [] },
|
|
create(ctx) {
|
|
if (!BARREL_BUCKET_ROOT_RE.test(barrelFilename(ctx))) return {}
|
|
return {
|
|
Program(node) {
|
|
ctx.report({ node, message: 'Bucket roots (types/utils/services) carry no barrel — import the specific file/topic (naming-conventions.md §6.4 rule 3 / §4.8).' })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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`.
|
|
// Both the `@renderer` alias and relative `**/renderer/**` paths are banned; the
|
|
// main i18n catalog now lives in `src/main/i18n`, and tests that need renderer
|
|
// catalog data read it from disk (fs) rather than importing it.
|
|
files: ['src/main/**/*.{ts,tsx,js,jsx}', 'src/preload/**/*.{ts,tsx,js,jsx}'],
|
|
rules: {
|
|
'@typescript-eslint/no-restricted-imports': [
|
|
'error',
|
|
{
|
|
patterns: [
|
|
{
|
|
group: ['@renderer', '@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 boundary block L: layer edges into shared buckets — Zone A (shared→pages/windows) + Zone C (utils impurity).
|
|
// Scoped to shared-bucket files so it never collides with block P on a pages file. Flips to error once A+C clear.
|
|
{
|
|
files: SHARED_BUCKET_FILES,
|
|
ignores: RENDERER_IGNORES,
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
RENDERER_BOUNDARY,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [
|
|
{
|
|
target: [
|
|
'src/renderer/components',
|
|
'src/renderer/hooks',
|
|
'src/renderer/services',
|
|
'src/renderer/utils'
|
|
],
|
|
from: ['src/renderer/pages', 'src/renderer/windows'],
|
|
message: 'Shared buckets must not import pages/windows (reverse layer edge). renderer-architecture.md §7.'
|
|
},
|
|
{
|
|
target: 'src/renderer/utils',
|
|
from: ['src/renderer/components', 'src/renderer/hooks'],
|
|
message: 'utils/ is stateless and may call downward infra (data/ipc) but must not import components/hooks or any higher app layer. renderer-architecture.md §3.'
|
|
},
|
|
// @logger is a §2 primitive that physically lives under services/. `from` uses a glob, so `except` must also glob.
|
|
{
|
|
target: 'src/renderer/utils',
|
|
from: ['src/renderer/services/**/*'],
|
|
except: ['**/LoggerService.ts'],
|
|
message: 'utils/ must not import renderer services (except @logger). renderer-architecture.md §3.'
|
|
},
|
|
...serviceBarrelZones
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Renderer boundary block P: page-targeted edges — B-pw (page→window) + B-pp (page→sibling-page).
|
|
// Scoped to pages files; both share one severity (one no-restricted-paths instance = one severity), held at warn
|
|
// until features-ization clears the sibling-page edges.
|
|
{
|
|
files: PAGE_FILES,
|
|
ignores: RENDERER_IGNORES,
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
PAGE_SIBLING,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [
|
|
{
|
|
target: 'src/renderer/pages',
|
|
from: 'src/renderer/windows',
|
|
message: 'A page must not import a window (reverse edge). renderer-architecture.md §2/§7.'
|
|
},
|
|
...pageSiblingZones,
|
|
...serviceBarrelZones
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Renderer boundary block B: topic-barrel guard for the importer regions blocks L/P do not cover
|
|
// (windows, routes, data, ipc, workers, …). Its `files` ignore the L and P scopes so it never shares a
|
|
// file with them — avoiding the flat-config last-wins collision noted above. Held at error like block L.
|
|
{
|
|
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: [...RENDERER_IGNORES, ...SHARED_BUCKET_FILES, ...PAGE_FILES],
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
RENDERER_BOUNDARY,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [...serviceBarrelZones]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Barrel / module-boundary rules (naming-conventions.md §6.4) — inline custom plugin, all error.
|
|
{
|
|
files: ['src/**/*.{ts,tsx}'],
|
|
// tests are exempt by design: white-box tests may deep-import a barrel's internals
|
|
ignores: ['src/**/*.test.*', 'src/**/__tests__/**', 'src/**/__mocks__/**'],
|
|
plugins: { barrel: barrelPlugin },
|
|
rules: {
|
|
'barrel/no-export-star': 'error',
|
|
'barrel/index-no-impl': 'error',
|
|
'barrel/no-index-tsx': 'error',
|
|
'barrel/named-only': 'error',
|
|
'barrel/closed': 'error',
|
|
'barrel/no-nesting': 'error',
|
|
'barrel/no-bucket-root': 'error'
|
|
}
|
|
},
|
|
// 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'
|
|
}
|
|
}
|
|
])
|