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