Ctrl K
ComunidadGuías…

eslint.config.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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' }
  }
])