eslint.config.mjs
Rama main · réplica de lectura del repositorio original.
import fs from 'node:fs'
import { builtinModules } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintReact from '@eslint-react/eslint-plugin'
import eslint from '@eslint/js'
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 unusedImports from 'eslint-plugin-unused-imports'
import { defineConfig } from 'eslint/config'
// --- 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). architecture/renderer.md §7.'
}))
// Topic barrels under services/: a services/<topic>/ exposes exactly one curated index.ts as its sole
// external entry (architecture/renderer.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}/!(index).{ts,tsx,js,jsx}`, `src/renderer/services/${topic}/!(index)/**/*`],
message: `services/${topic}/ is a topic barrel — import @renderer/services/${topic} (its index.ts), not its internals. architecture/renderer.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'
// --- import bans (@typescript-eslint/no-restricted-imports) ---
// Flat config replaces a rule wholesale rather than merging it, so every ban that
// applies to the same files must live in ONE `patterns` array under ONE rule name.
// To add a ban: define it here and list it in the scope block(s) below — never reach
// for a second rule name (e.g. the base `no-restricted-imports`) to dodge the override.
// To exempt a single sanctioned call site, put an eslint-disable comment on its import.
const BAN_RENDERER_FROM_MAIN = {
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/dev/references/architecture/shared-layer.md.'
}
// Only reaches src/main + src/preload (below). `tests/**` is globally ignored by this
// config, so the out-of-src harness is not covered — it goes through applyMigrations by
// convention, not by enforcement.
const BAN_DRIZZLE_MIGRATOR = {
group: ['drizzle-orm/*/migrator'],
message:
"Do not call drizzle's migrate() directly — its transaction makes drizzle-kit's `PRAGMA foreign_keys=OFF` a no-op, so any table-recreate migration silently cascades child rows away. Use applyMigrations() from @data/db/applyMigrations."
}
// MCP SDK v1 stays installed only as the Claude Agent SDK's type peer; app code uses v2.
const BAN_MCP_SDK_V1 = {
group: ['@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/**'],
message:
'MCP SDK v1 is only a type peer of the Claude Agent SDK. Use @modelcontextprotocol/client, @modelcontextprotocol/server, or @modelcontextprotocol/core.'
}
const BAN_WEB_SEARCH_INTERNALS = {
group: ['**/packages/web-search/src/**', '@cherrystudio/web-search/src/**', '@cherrystudio/web-search/dist/**'],
message: 'Use the public @cherrystudio/web-search entries instead of importing package internals.'
}
// File preview is a portable package: host-app modules are off-limits to its source.
const FILE_PREVIEW_HOST_IMPORTS = [
'@renderer',
'@renderer/**',
'@shared',
'@shared/**',
'@data',
'@data/**',
'@test-mocks',
'@test-mocks/**',
'@logger',
'electron',
'**/src/renderer/**',
'**/src/shared/**',
'**/src/main/**'
]
// Agent behavior must run unchanged on coding-agent, Durable and the fake (packages/agent-behavior/README.md).
const AGENT_BEHAVIOR_PORTABLE =
'Agent behavior is harness-neutral: no Pi, Node, Electron, host, logger or AI SDK modules.'
const AGENT_BEHAVIOR_IMPORTS = {
paths: [
{
name: '@cherrystudio/agent-runtime',
allowTypeImports: true,
message: 'Agent behavior imports only types from @cherrystudio/agent-runtime.'
},
...['ai', 'electron', '@logger', '@application', ...builtinModules].map((name) => ({
name,
message: AGENT_BEHAVIOR_PORTABLE
}))
],
patterns: [
BAN_MCP_SDK_V1,
BAN_WEB_SEARCH_INTERNALS,
{
group: [
'node:*',
'electron/**',
'@earendil-works/*',
'@earendil-works/**',
'ai/**',
'@ai-sdk/*',
'@ai-sdk/**',
'@main/**',
'@shared/**',
'@renderer/**',
'@data/**',
'@cherrystudio/agent-runtime-pi',
'@cherrystudio/agent-runtime-pi/**',
'@cherrystudio/agent-runtime-pi-durable',
'@cherrystudio/agent-runtime-pi-durable/**',
'**/src/main/**',
'**/src/shared/**',
'**/src/renderer/**'
],
message: AGENT_BEHAVIOR_PORTABLE
}
]
}
// One import rule per layer: flat config replaces a rule wholesale, so each layer repeats the package bans.
const agentBehaviorLayer = (regex, message) => [
'error',
{ ...AGENT_BEHAVIOR_IMPORTS, patterns: [...AGENT_BEHAVIOR_IMPORTS.patterns, { regex, message }] }
]
const AGENT_RUNTIME_TYPE_NAMES =
/^(?:RuntimeId|BackendId|ThreadId|InputId|ItemId|DelegationId|NativeInputId|JsonValue|ContentPart)$/
const AGENT_RUNTIME_SELECTORS = [
{
selector: `ImportDeclaration[source.value='@cherrystudio/agent-runtime'] > ImportSpecifier[imported.name!=${AGENT_RUNTIME_TYPE_NAMES}]`,
message:
'Take only ids, JsonValue and ContentPart from @cherrystudio/agent-runtime; observation items are not history.'
},
{
selector:
"ImportDeclaration[source.value='@cherrystudio/agent-runtime'] > :matches(ImportNamespaceSpecifier, ImportDefaultSpecifier)",
message: 'Import named id types from @cherrystudio/agent-runtime.'
}
]
const KEY_MINT_SELECTORS = [
{
selector: "TSAsExpression[typeAnnotation.typeName.name='Key'], TSTypeAssertion[typeAnnotation.typeName.name='Key']",
message: 'Keys come only from core/keys.ts.'
}
]
const CORE_SYNC_SELECTORS = [
{ selector: ':function[async=true]', message: 'Core is synchronous so it can run inside a commit.' },
{ selector: 'AwaitExpression', message: 'Core is synchronous so it can run inside a commit.' },
{ selector: 'YieldExpression', message: 'Core is synchronous so it can run inside a commit.' },
{ selector: 'Identifier[name=/^Promise/]', message: 'Core is synchronous so it can run inside a commit.' }
]
const CORE_ENVIRONMENT = 'Core does not touch the environment; take time and ids as facts.'
const AGENT_BEHAVIOR_TIMERS = [
'setTimeout',
'setInterval',
'setImmediate',
'clearTimeout',
'clearInterval',
'queueMicrotask'
]
// Utility-process child code (protocol/runtime, entries, smoke entries) is bundled for a
// separate process that has no lifecycle container, no logger, and no database. Importing a
// main-only singleton there fails at runtime — or silently drags winston/Drizzle into the
// child bundle. A resolved-path zone, so relative and aliased specifiers are judged alike;
// the smoke build's entry-graph guard (scripts/utility-process-smoke/hermeticEntryGuardPlugin.ts)
// is the transitive backstop (docs/dev/references/utility-process/README.md).
const UTILITY_CHILD_FILES = [
'src/main/core/utilityProcess/protocol/**/*.ts',
'src/main/core/utilityProcess/runtime/**/*.ts',
'src/main/**/utilityEntries/**/*.ts',
'scripts/utility-process-smoke/harness/utilityEntries/**/*.ts'
]
const UTILITY_CHILD_ZONE = {
target: UTILITY_CHILD_FILES,
from: [
'src/main/core/application',
'src/main/core/lifecycle',
'src/main/core/logger',
'src/main/core/paths',
'src/main/data',
'src/main/ipc',
'src/main/services/proxy',
'src/main/core/utilityProcess/host',
'src/main/core/utilityProcess/UtilityProcessManager.ts'
],
message:
'Utility-process child code runs without the main process singletons. Use the child runtime (serveUtilityProcess) and the protocol layer instead; keep host-only code out of the entry graph.'
}
// Resolved against the node project: tsconfig.web.json maps @logger / @data/* to renderer files.
const mainBoundarySettings = {
'import-x/resolver-next': [
createTypeScriptImportResolver({ project: path.join(RENDERER_DIRNAME, 'tsconfig.node.json'), alwaysTryTypes: true })
]
}
// --- 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 RUNTIME_PACKAGES = [
'agent-runtime',
'agent-behavior',
'agent-runtime-pi',
'agent-runtime-pi-durable',
'agent-runtime-claude-code'
]
const RUNTIME_SOURCE_DIRS = RUNTIME_PACKAGES.map((name) => path.join(RENDERER_DIRNAME, 'packages', name, 'src'))
const BARREL_DIRS = new Set()
for (const idx of [SRC_DIR, ...RUNTIME_SOURCE_DIRS].flatMap((dir) => collectIndexTs(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/*` except the explicitly sealed runtime packages.
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))
else {
for (const name of RUNTIME_PACKAGES) {
const prefix = `@cherrystudio/${name}`
if (spec === prefix) base = path.join(RENDERER_DIRNAME, 'packages', name, 'src')
else if (spec.startsWith(`${prefix}/`))
base = path.join(RENDERER_DIRNAME, 'packages', name, 'src', spec.slice(prefix.length + 1))
}
}
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).'
})
}
}
}
}
}
}
// --- directory & file naming rules (naming-conventions.md §3–§4, §6.6) ---
// Inline custom plugin (like `barrel` above). ESLint is per-file, so a directory name is checked by
// deriving each ancestor segment from the linted file's path (a dir with no linted file is thus
// invisible — acceptable: every code dir has a .ts/.tsx). Only zones where path → role is
// deterministic are enforced; the semantic splits left to review are bucket-vs-domain plural/singular
// (§4.9) and class-file PascalCase vs function-file camelCase (§3.2). Acronym-internal casing (§6.1)
// is also out of scope here.
const isKebabName = (s) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)
const isCamelName = (s) => /^[a-z][a-zA-Z0-9]*$/.test(s)
const isPascalName = (s) => /^[A-Z][a-zA-Z0-9]*$/.test(s)
const isRouteToken = (s) => s.startsWith('$') || s.startsWith('_') // $appId, $, __root, _pathless
const isModuleFileStem = (s) => {
const b = s.replace(/^_+/, '') // _columnHelpers.ts: leading-underscore shared construct (§3.2)
return isCamelName(b) || isPascalName(b)
}
const NAMING_EXEMPT_DIRS = new Set(['__tests__', '__mocks__', '__snapshots__'])
// First matching prefix wins; unmanaged zones bail before the generic renderer/src zones.
const NAMING_ZONES = [
{
prefix: 'packages/ui/',
root: 2,
label: 'packages/ui',
dir: isKebabName,
dirExpect: 'kebab-case',
file: isKebabName,
fileExpect: 'kebab-case'
},
{
prefix: 'src/renderer/routes/',
root: 3,
label: 'routes',
dir: (s) => isKebabName(s) || isRouteToken(s),
dirExpect: 'kebab-case',
file: (s) => isKebabName(s) || isRouteToken(s),
fileExpect: 'kebab-case'
},
{ prefix: 'src/renderer/assets/', unmanaged: true },
{
prefix: 'src/renderer/',
root: 2,
label: 'src/renderer',
dir: (s) => isCamelName(s) || isPascalName(s),
dirExpect: 'camelCase (module) or PascalCase (component)',
file: isModuleFileStem,
fileExpect: 'camelCase or PascalCase'
},
{
prefix: 'src/main/',
root: 2,
label: 'src/main',
dir: isCamelName,
dirExpect: 'camelCase',
file: isModuleFileStem,
fileExpect: 'camelCase or PascalCase'
},
{
prefix: 'src/shared/',
root: 2,
label: 'src/shared',
dir: isCamelName,
dirExpect: 'camelCase',
file: isModuleFileStem,
fileExpect: 'camelCase or PascalCase'
},
{
prefix: 'src/preload/',
root: 2,
label: 'src/preload',
dir: isCamelName,
dirExpect: 'camelCase',
file: isModuleFileStem,
fileExpect: 'camelCase or PascalCase'
}
]
const namingReportedDirs = new Set()
const namingPlugin = {
rules: {
'path-case': {
meta: { type: 'problem', schema: [] },
create(ctx) {
const abs = ctx.filename ?? ctx.getFilename()
const rel = path.relative(RENDERER_DIRNAME, abs).split(path.sep).join('/')
const zone = NAMING_ZONES.find((z) => rel.startsWith(z.prefix))
if (!zone || zone.unmanaged) return {}
return {
Program(node) {
const parts = rel.split('/')
const fileName = parts[parts.length - 1]
const dirSegs = parts.slice(zone.root, parts.length - 1)
for (let i = 0; i < dirSegs.length; i++) {
const seg = dirSegs[i]
if (seg.startsWith('.') || NAMING_EXEMPT_DIRS.has(seg) || zone.dir(seg)) continue // dotdirs (.storybook, .github) are tool conventions
const dirRel = parts.slice(0, zone.root + i + 1).join('/')
if (namingReportedDirs.has(dirRel)) continue
namingReportedDirs.add(dirRel)
ctx.report({
node,
message: `Directory \`${dirRel}\`: segment \`${seg}\` must be ${zone.dirExpect} under ${zone.label} (naming-conventions.md §4).`
})
}
if (/^index\.tsx?$/.test(fileName) || /\.d\.ts$/.test(fileName)) return // index.* owned by barrel/*; *.d.ts by §3.5
const stem = fileName.split('.')[0]
if (!stem || zone.file(stem)) return
ctx.report({
node,
message: `File \`${fileName}\`: name \`${stem}\` must be ${zone.fileExpect} under ${zone.label} (naming-conventions.md §3).`
})
}
}
}
}
}
}
export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
eslintReact.configs['recommended-typescript'],
// The v7 presets also enable React Compiler rules; this app does not use the compiler.
{
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
},
{
plugins: {
'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',
'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',
'native/system-speech/dist',
'packages/**/storybook-static/**',
'scripts/data-classify/**'
]
},
// 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 and lifecycle Emitter 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/dev/references/logging/README.md\n\n'
},
{
selector: 'CallExpression[callee.property.name="registerDisposable"] > NewExpression[callee.name="Emitter"]',
message:
'❗Do not registerDisposable() an Emitter the service owns: stop would dispose it for good. Dispose it in onDestroy(): 📖 docs/dev/references/lifecycle/lifecycle-usage.md\n\n'
}
]
}
},
// Seeder write integrity — a seeder runs again whenever its `version` changes
// (SeedRunner journal), so an overwrite-style upsert silently rewrites data
// the user or a later seeder already established. This is how the legacy
// web-search preference upgrade clobbered `chat.web_search.model_tools_
// preferred` (fixed in #20686): `onConflictDoUpdate` force-wrote `!legacy`
// over the renamed key on every re-run. Seeders must insert-if-absent, guard
// on absence, or scope the conflict with `setWhere`. Sanctioned overwrites
// are exempted inline with a reason.
{
files: ['src/main/data/db/seeding/**/*.ts'],
ignores: ['src/main/data/db/seeding/**/__tests__/**', 'src/main/data/db/seeding/**/*.test.ts'],
rules: {
'no-restricted-syntax': [
process.env.CI ? 'error' : 'warn',
// Flat-config rule arrays replace the earlier src-wide entry instead of
// merging, so the LoggerService restriction is restated here to keep
// applying to production seeder files.
{
selector: 'CallExpression[callee.object.name="console"]',
message: '❗CherryStudio uses unified LoggerService: 📖 docs/dev/references/logging/README.md\n\n'
},
{
selector: 'CallExpression[callee.property.name="onConflictDoUpdate"]',
message:
'❗A seeder must not overwrite existing rows — use insert-if-absent (onConflictDoNothing) or scope the conflict with setWhere. If an overwrite is genuinely intended, exempt it inline with a reason.\n\n'
}
]
}
},
// Path brand integrity — `as AbsoluteFilePath` / `as CanonicalFilePath` forge the
// brands, skipping the validation each asserts. `AbsoluteFilePath` asserts shape
// validation (build via AbsoluteFilePathSchema.parse); `CanonicalFilePath` asserts
// the byte-faithful lexical form that backs the external-path dedup key
// (build via the canonicalizeFilePath() factory). A forged
// `as CanonicalFilePath` silently bypasses canonicalization and can write a
// ghost-duplicate key, so the stronger brand is guarded too.
// Exemptions: test fixtures; and one deliberate raw-OS-path regime — the
// tree builder (tree/**) holds raw chokidar/OS event paths that are compared
// byte-for-byte against event paths and are trusted as-is, so they `as AbsoluteFilePath`
// rather than routing through validation.
{
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/main/services/file/tree/**', 'src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'],
plugins: {
'filepath-brand': {
rules: {
'no-as-filepath': {
meta: {
type: 'problem',
docs: {
description:
'Disallow `as AbsoluteFilePath` / `as CanonicalFilePath` casts. Both are Zod-derived brands: AbsoluteFilePath asserts shape validation (absolute path, no null bytes), CanonicalFilePath additionally asserts the byte-faithful lexical form backing the dedup key. Forging either bypasses its validation. Construct via AbsoluteFilePathSchema.parse() / canonicalizeFilePath().',
recommended: true
},
messages: {
noAsFilePath:
"`as AbsoluteFilePath` forges the brand, skipping AbsoluteFilePathSchema's absolute-path validation. Build it with AbsoluteFilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.",
noAsCanonicalFilePath:
'`as CanonicalFilePath` forges the brand, skipping canonicalization — a non-canonical value silently becomes a ghost-duplicate dedup key. Build it with canonicalizeFilePath(value) instead, or justify the sanctioned producer with an eslint-disable + reason.'
}
},
create(context) {
// Matches both `x as T` (TSAsExpression) and `<T>x` (TSTypeAssertion).
// Limitation: name-based, so an aliased import (`import { AbsoluteFilePath as AFP }`
// then `x as AFP`) is not caught — aliasing a brand solely to forge it is not a
// real-world pattern, and resolving aliases would require full scope analysis.
function checkAssertion(node) {
const ann = node.typeAnnotation
if (ann?.type !== 'TSTypeReference' || ann.typeName?.type !== 'Identifier') return
if (ann.typeName.name === 'AbsoluteFilePath') {
context.report({ node, messageId: 'noAsFilePath' })
} else if (ann.typeName.name === 'CanonicalFilePath') {
context.report({ node, messageId: 'noAsCanonicalFilePath' })
}
}
return {
TSAsExpression: checkAssertion,
TSTypeAssertion: checkAssertion
}
}
}
}
}
},
rules: {
'filepath-brand/no-as-filepath': process.env.CI ? 'error' : 'warn'
}
},
// 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/dev/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'
}
},
// Transaction callers keep their database handle through same-class helpers.
// Registry resolution accepts explicit context and is covered by the no-global-DB regressions.
{
files: ['src/main/**/*.{ts,tsx}'],
ignores: ['src/main/**/__tests__/**', 'src/main/**/__mocks__/**', 'src/main/**/*.test.*'],
plugins: {
'tx-boundary': {
rules: {
'no-ambient-db-in-tx': {
meta: {
type: 'problem',
schema: [
{
type: 'object',
properties: {
followHelpers: { type: 'boolean' },
databaseFreeMethods: { type: 'array', items: { type: 'string' } }
},
additionalProperties: false
}
],
docs: {
description: 'Disallow ambient database access in transaction methods and their same-class helpers.'
},
messages: {
ambientDb:
'"{{name}}" is reachable from `{{fn}}` and depends on DbService being ready. Use the `tx` parameter.',
contextLookup: '`{{fn}}` must resolve supplied context without looking up another data service.',
serviceEscape:
'`{{name}}` is reachable from `{{fn}}`. Use a `*Tx` method or an explicit-context resolver.'
}
},
create(context) {
const { followHelpers = false, databaseFreeMethods = [] } = context.options[0] ?? {}
const functions = new Map()
const scopes = []
const declaredName = (node) => {
const parent = node.parent
if (parent?.type === 'MethodDefinition' || parent?.type === 'Property') {
return parent.key?.type === 'Identifier' ? parent.key.name : null
}
if (parent?.type === 'VariableDeclarator') {
return parent.id?.type === 'Identifier' ? parent.id.name : null
}
return node.id?.type === 'Identifier' ? node.id.name : null
}
const enter = (node) => {
const parent = scopes.at(-1)
const classBody = node.parent?.type === 'MethodDefinition' ? node.parent.parent : parent?.classBody
const scope = { name: declaredName(node), classBody, calls: [], members: [], children: [] }
if (parent && parent.classBody === classBody) parent.children.push(scope)
functions.set(node, scope)
scopes.push(scope)
}
const exit = () => scopes.pop()
const inspectCall = (node, fn) => {
const { callee } = node
if (
callee.type === 'Identifier' &&
callee.name === 'getDataService' &&
databaseFreeMethods.includes(fn)
) {
return { node, messageId: 'contextLookup', data: { fn } }
}
if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier') return
const method = callee.property.name
if (method === 'getDb') {
return { node, messageId: 'ambientDb', data: { name: 'getDb()', fn } }
}
if (callee.object.type !== 'Identifier') return
const receiver = callee.object.name
if (receiver === 'application' && method === 'get' && node.arguments[0]?.value === 'DbService') {
return { node, messageId: 'ambientDb', data: { name: "application.get('DbService')", fn } }
}
if (receiver === 'providerRegistryService' && method === 'resolveModel') return
if (receiver.endsWith('Service') && !method.endsWith('Tx')) {
return { node, messageId: 'serviceEscape', data: { name: `${receiver}.${method}()`, fn } }
}
}
return {
FunctionDeclaration: enter,
'FunctionDeclaration:exit': exit,
FunctionExpression: enter,
'FunctionExpression:exit': exit,
ArrowFunctionExpression: enter,
'ArrowFunctionExpression:exit': exit,
CallExpression(node) {
scopes.at(-1)?.calls.push(node)
},
MemberExpression(node) {
if (node.object.type === 'ThisExpression' && node.property.type === 'Identifier') {
scopes.at(-1)?.members.push(node.property.name)
}
},
'Program:exit'() {
const reported = new Set()
for (const root of functions.values()) {
if (!root.name?.endsWith('Tx') && !databaseFreeMethods.includes(root.name)) continue
const visited = new Set()
const visit = (scope) => {
if (!scope || visited.has(scope)) return
visited.add(scope)
for (const call of scope.calls) {
const diagnostic = inspectCall(call, root.name)
if (diagnostic && !reported.has(call)) {
context.report(diagnostic)
reported.add(call)
}
}
for (const child of scope.children) visit(child)
if (!followHelpers) return
for (const name of scope.members) {
const method = scope.classBody?.body.find(
(member) =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === name
)
if (method) visit(functions.get(method.value))
}
}
visit(root)
}
}
}
}
}
}
}
},
rules: {
'tx-boundary/no-ambient-db-in-tx': 'error'
}
},
{
files: ['src/main/data/services/ModelService.ts', 'src/main/data/services/ProviderRegistryService.ts'],
rules: {
'tx-boundary/no-ambient-db-in-tx': ['error', { followHelpers: true, databaseFreeMethods: ['resolveModel'] }]
}
},
// i18n
{
files: ['**/*.{ts,tsx,js,jsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module'
},
plugins: {
i18n: {
rules: {
'no-template-in-t': {
meta: {
type: 'problem',
docs: {
description: '⚠️ Avoid template literals in t() — they make rendering output unpredictable',
recommended: true
},
messages: {
noTemplateInT: '⚠️ Avoid template literals in t() — they make rendering output unpredictable'
}
},
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: [
BAN_MCP_SDK_V1,
BAN_WEB_SEARCH_INTERNALS,
{
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.'
}
]
}
]
}
},
{
files: ['src/shared/**/*.{ts,tsx,js,jsx}'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [BAN_MCP_SDK_V1, BAN_WEB_SEARCH_INTERNALS]
}
]
}
},
{
// Renderer tests are excluded from the bundle-value guard above, but still must not use MCP v1.
files: ['src/renderer/**/*.test.*', 'src/renderer/**/__tests__/**', 'src/renderer/**/__mocks__/**'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [BAN_MCP_SDK_V1, BAN_WEB_SEARCH_INTERNALS]
}
]
}
},
{
// Import bans for main/preload — see the BAN_* definitions above for each one's rationale.
//
// 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: [BAN_MCP_SDK_V1, BAN_WEB_SEARCH_INTERNALS, BAN_RENDERER_FROM_MAIN, BAN_DRIZZLE_MIGRATOR]
}
]
}
},
{
// Child-safe zone: everything that is bundled into a utility process entry. Must come
// after the src/main block — flat config replaces a rule wholesale, so the main bans are
// repeated here; the child-only fence is the resolved-path zone.
files: UTILITY_CHILD_FILES,
plugins: { 'import-x': importX },
settings: mainBoundarySettings,
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{ patterns: [BAN_WEB_SEARCH_INTERNALS, BAN_RENDERER_FROM_MAIN, BAN_DRIZZLE_MIGRATOR] }
],
'import-x/no-restricted-paths': ['error', { basePath: RENDERER_DIRNAME, zones: [UTILITY_CHILD_ZONE] }]
}
},
{
// Workspace packages and repository scripts follow the same MCP v1 ban as src/.
files: [
'packages/**/*.{ts,tsx}',
'native/system-speech/**/*.{ts,tsx}',
'scripts/**/*.{ts,tsx}',
'tests/**/*.{ts,tsx}',
'v2-refactor-temp/**/*.{ts,tsx}'
],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [BAN_MCP_SDK_V1, BAN_WEB_SEARCH_INTERNALS]
}
]
}
},
{
files: ['packages/web-search/src/**/*.ts'],
ignores: ['packages/web-search/src/**/__tests__/**'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
BAN_MCP_SDK_V1,
{
group: [
'node:*',
'electron',
'electron/**',
'react',
'react-native',
'react-native/**',
'expo',
'expo-*',
'@application',
'@logger',
'@main/**',
'@renderer/**',
'@shared/**',
'@data/**',
'@test-mocks/**',
'**/src/main/**',
'**/src/renderer/**',
'**/src/shared/**',
'**/src/backend/**'
],
message: 'Web search protocols must use portable dependencies and host-injected transport.'
}
]
}
]
}
},
{
files: ['packages/file-preview/src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
BAN_MCP_SDK_V1,
BAN_WEB_SEARCH_INTERNALS,
{
group: FILE_PREVIEW_HOST_IMPORTS,
message: 'File preview must use its portable source and host callbacks.'
}
]
}
],
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="window"][property.name="api"]',
message: 'File preview must not access the Electron preload bridge.'
},
{
selector: 'MemberExpression[object.name="window"][property.value="api"]',
message: 'File preview must not access the Electron preload bridge.'
}
]
}
},
{
files: ['packages/file-preview/src/{core,source,selection,documentAnchor,officeZipPreflight}.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
BAN_MCP_SDK_V1,
BAN_WEB_SEARCH_INTERNALS,
{
group: [
...FILE_PREVIEW_HOST_IMPORTS,
'react',
'react-dom',
'react-dom/**',
'@cherrystudio/ui',
'@cherrystudio/ui/**',
'./plugins/**',
'./Preview',
'./react'
],
message: 'The preview core has no DOM or React dependencies.'
}
]
}
],
'no-restricted-globals': ['error', 'window', 'document', 'Worker']
}
},
{
files: ['packages/agent-behavior/src/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': ['error', AGENT_BEHAVIOR_IMPORTS],
'no-restricted-syntax': ['error', ...AGENT_RUNTIME_SELECTORS]
}
},
{
files: ['packages/agent-behavior/src/core/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': agentBehaviorLayer(
'(?:^|/)(?:ports|orchestration|fake|contract|index)(?:[./]|$)|^(?:\\.\\./)*\\.\\.?/?$',
'core/ imports only core/.'
),
'no-restricted-syntax': [
'error',
...AGENT_RUNTIME_SELECTORS,
...KEY_MINT_SELECTORS,
...CORE_SYNC_SELECTORS,
{
selector: 'MemberExpression[property.name=/^(?:localeCompare|toLocale)/]',
message: 'Core does not depend on the host locale.'
}
],
'no-restricted-globals': [
'error',
...['Date', 'Intl', 'crypto', 'performance', 'process', 'fetch', 'globalThis', ...AGENT_BEHAVIOR_TIMERS].map(
(name) => ({
name,
message: CORE_ENVIRONMENT
})
)
],
'no-restricted-properties': ['error', { object: 'Math', property: 'random', message: CORE_ENVIRONMENT }]
}
},
{
files: ['packages/agent-behavior/src/ports/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': agentBehaviorLayer(
'(?:^|/)(?:orchestration|fake|contract|index)(?:[./]|$)|^(?:\\.\\./)*\\.\\.?/?$',
'ports/ depends only on core/ types.'
),
'no-restricted-syntax': [
'error',
...AGENT_RUNTIME_SELECTORS,
{
selector:
':matches(FunctionDeclaration, ClassDeclaration, VariableDeclaration, TSEnumDeclaration, ExpressionStatement)',
message: 'ports/ holds interfaces and TSDoc only.'
},
{ selector: "ImportDeclaration[importKind='value']", message: 'ports/ imports types only.' }
]
}
},
{
files: ['packages/agent-behavior/src/orchestration/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': agentBehaviorLayer(
'(?:^|/)(?:fake|contract|index)(?:[./]|$)|^(?:\\.\\./)*\\.\\.?/?$',
'Orchestration never depends on the fake or the contract.'
),
'no-restricted-syntax': [
'error',
...AGENT_RUNTIME_SELECTORS,
...KEY_MINT_SELECTORS,
{
selector:
"Program > VariableDeclaration[kind!='const'], Program > ExportNamedDeclaration > VariableDeclaration[kind!='const']",
message: 'Orchestration keeps no module state.'
},
{ selector: "NewExpression[callee.name='Promise']", message: 'Orchestration waits only through ports.' },
{
selector:
"Program > VariableDeclaration > VariableDeclarator[init.type='NewExpression'], Program > ExportNamedDeclaration > VariableDeclaration > VariableDeclarator[init.type='NewExpression'], Program > VariableDeclaration > VariableDeclarator[init.type='TSAsExpression'][init.expression.type='NewExpression'], Program > ExportNamedDeclaration > VariableDeclaration > VariableDeclarator[init.type='TSAsExpression'][init.expression.type='NewExpression']",
message: 'Orchestration keeps no module state.'
},
{
selector: ':matches(ClassDeclaration, ClassExpression)',
message: 'Orchestration keeps no instance state; write functions over ports.'
},
{
selector: "MemberExpression[object.name='Promise'][property.name='withResolvers']",
message: 'Orchestration waits only through ports.'
}
],
'no-restricted-globals': [
'error',
...AGENT_BEHAVIOR_TIMERS.map((name) => ({ name, message: 'Orchestration waits only through ports.' }))
]
}
},
{
files: ['packages/agent-behavior/src/contract/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': agentBehaviorLayer(
'(?:^|/)(?:orchestration|fake|index)(?:[./]|$)|^(?:\\.\\./)*\\.\\.?/?$',
'contract/ depends only on core/ and ports/.'
)
}
},
// 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). architecture/renderer.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. architecture/renderer.md §3.'
},
// @logger is a §2 primitive that physically lives under services/; keep it out of the restricted glob.
{
target: 'src/renderer/utils',
from: [
'src/renderer/services/!(LoggerService).{ts,tsx,js,jsx}',
'src/renderer/services/!(LoggerService)/**/*'
],
message: 'utils/ must not import renderer services (except @logger). architecture/renderer.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). architecture/renderer.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}', ...RUNTIME_PACKAGES.map((name) => `packages/${name}/src/**/*.ts`)],
// 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'
}
},
{
files: ['packages/*/src/**/*.{ts,tsx}', 'native/system-speech/src/**/*.{ts,tsx}'],
ignores: [...RUNTIME_PACKAGES.map((name) => `packages/${name}/**`), '**/*.test.*', '**/__tests__/**'],
plugins: { barrel: barrelPlugin },
rules: { 'barrel/closed': 'error' }
},
// Directory & file naming rules (naming-conventions.md §3–§4, §6.6) — inline custom plugin.
// Not tests-exempt (test file names follow the convention too); the __tests__/__mocks__/__snapshots__
// directory segments are allow-listed inside the rule.
{
files: ['src/**/*.{ts,tsx}', 'packages/ui/**/*.{ts,tsx}'],
plugins: { naming: namingPlugin },
rules: {
'naming/path-case': 'error'
}
},
// 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'
}
},
{
files: ['src/renderer/**/*.{ts,tsx}', 'packages/ui/src/**/*.{ts,tsx}'],
ignores: [
'packages/ui/src/lib/code-highlight/**',
'packages/ui/src/code-highlight.ts',
'packages/ui/src/lib/code-block/**',
'packages/ui/src/code-block.ts'
],
plugins: {
'highlight-boundary': {
rules: {
'public-entry': {
meta: {
type: 'problem',
schema: [],
messages: {
private:
'Import highlighting APIs through @cherrystudio/ui/code-highlight or @cherrystudio/ui/code-block.'
}
},
create(context) {
return {
ImportDeclaration(node) {
if (
typeof node.source.value === 'string' &&
/lib\/(code-highlight|code-block)/.test(node.source.value)
)
context.report({ node, messageId: 'private' })
}
}
}
}
}
}
},
rules: { 'highlight-boundary/public-entry': 'error' }
}
])