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// 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 (`.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 `.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' } } ])