mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
- add an auto-discovered import-x/no-restricted-paths guard so each services/<topic>/ is reachable only through its index.ts barrel (blocks L/P/B, renderer-architecture.md §3.1/§5) - StreamDispatchService: normalize the module-level listener registry into a class singleton - copy.ts: drop the Service suffix from the stateless clipboard helper (stays in services/ for the side effect) - move stateless reads to utils/: skillSearch.ts and model/resolve.ts (reads never promote) - ImportService: fix the direct-import singleton form (class ImportService + importService instance)
670 lines
26 KiB
JavaScript
670 lines
26 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import tseslint from '@electron-toolkit/eslint-config-ts'
|
|
import eslint from '@eslint/js'
|
|
import eslintReact from '@eslint-react/eslint-plugin'
|
|
import { defineConfig } from 'eslint/config'
|
|
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
|
import importX from 'eslint-plugin-import-x'
|
|
import importZod from 'eslint-plugin-import-zod'
|
|
import oxlint from 'eslint-plugin-oxlint'
|
|
import reactHooks from 'eslint-plugin-react-hooks'
|
|
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
|
import unusedImports from 'eslint-plugin-unused-imports'
|
|
|
|
const LEGACY_RENDERER_CSS_VARS = [
|
|
'--color-text-1',
|
|
'--color-text-2',
|
|
'--color-text-3',
|
|
'--color-text',
|
|
'--color-text-secondary',
|
|
'--color-text-soft',
|
|
'--color-text-light',
|
|
'--color-background-soft',
|
|
'--color-background-mute',
|
|
'--color-background-opacity',
|
|
'--color-border-soft',
|
|
'--color-border-mute',
|
|
'--color-error',
|
|
'--color-link',
|
|
'--color-primary-bg',
|
|
'--color-fill-secondary',
|
|
'--color-fill-2',
|
|
'--color-bg-base',
|
|
'--color-bg-1',
|
|
'--color-code-background',
|
|
'--color-inline-code-background',
|
|
'--color-inline-code-text',
|
|
'--color-hover',
|
|
'--color-active',
|
|
'--color-frame-border',
|
|
'--color-group-background',
|
|
'--color-reference',
|
|
'--color-reference-text',
|
|
'--color-reference-background',
|
|
'--color-list-item',
|
|
'--color-list-item-hover',
|
|
'--color-highlight',
|
|
'--color-background-highlight',
|
|
'--color-background-highlight-accent',
|
|
'--navbar-background-mac',
|
|
'--navbar-background',
|
|
'--modal-background',
|
|
'--chat-background',
|
|
'--chat-background-user',
|
|
'--chat-background-assistant',
|
|
'--chat-text-user',
|
|
'--list-item-border-radius',
|
|
'--color-gray-1',
|
|
'--color-gray-2',
|
|
'--color-gray-3',
|
|
'--color-icon-white',
|
|
'--color-primary-1',
|
|
'--color-primary-6',
|
|
'--color-status-success',
|
|
'--color-status-error',
|
|
'--color-status-warning'
|
|
]
|
|
|
|
const LEGACY_RENDERER_CSS_VAR_REGEX = new RegExp(
|
|
`(${LEGACY_RENDERER_CSS_VARS.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![\\w-])`,
|
|
'g'
|
|
)
|
|
|
|
// --- renderer dependency-direction boundary gate (import-x/no-restricted-paths) ---
|
|
const RENDERER_DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
|
const PAGE_DOMAINS = fs
|
|
.readdirSync(path.join(RENDERER_DIRNAME, 'src/renderer/pages'), { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name)
|
|
|
|
// A page must not import a sibling page domain; its own subtree is allowed via `except` (resolved relative to `from`).
|
|
const pageSiblingZones = PAGE_DOMAINS.map((p) => ({
|
|
target: `src/renderer/pages/${p}`,
|
|
from: 'src/renderer/pages',
|
|
except: [`./${p}`],
|
|
message: 'A page must not import another page (cross-page coupling). renderer-architecture.md §7.'
|
|
}))
|
|
|
|
// Topic barrels under services/: a services/<topic>/ exposes exactly one curated index.ts as its sole
|
|
// external entry (renderer-architecture.md §3.1/§5). Auto-discovered from the filesystem so a new topic dir
|
|
// needs zero rule edits — mirrors pageSiblingZones above. A topic's own subtree is excluded from `target`
|
|
// (extglob negation), so internal `./sibling` imports stay legal while every outside importer is limited to
|
|
// the barrel. Applied in every renderer importer region via blocks L/P/B below.
|
|
const SERVICES_DIR = path.join(RENDERER_DIRNAME, 'src/renderer/services')
|
|
const serviceTopics = fs
|
|
.readdirSync(SERVICES_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && d.name !== '__tests__' && d.name !== '__mocks__')
|
|
.filter((d) => fs.existsSync(path.join(SERVICES_DIR, d.name, 'index.ts')))
|
|
.map((d) => d.name)
|
|
|
|
const serviceBarrelZones = serviceTopics.map((topic) => ({
|
|
target: [
|
|
`src/renderer/!(services)/**/*`, // importers outside services/ entirely
|
|
`src/renderer/services/!(${topic})/**/*`, // sibling topic dirs
|
|
`src/renderer/services/*` // flat files at the services/ root
|
|
],
|
|
from: `src/renderer/services/${topic}/**/*`,
|
|
except: ['**/index.ts'], // the barrel itself stays importable
|
|
message: `services/${topic}/ is a topic barrel — import @renderer/services/${topic} (its index.ts), not its internals. renderer-architecture.md §3.1/§5.`
|
|
}))
|
|
|
|
// Each block's `files` is scoped so the three no-restricted-paths instances (L/P/B) never both apply to one
|
|
// file — flat config merges rules by key (last-wins), which would otherwise drop one block silently.
|
|
const SHARED_BUCKET_FILES = [
|
|
'src/renderer/components/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/hooks/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/services/**/*.{ts,tsx,js,jsx}',
|
|
'src/renderer/utils/**/*.{ts,tsx,js,jsx}'
|
|
]
|
|
const PAGE_FILES = ['src/renderer/pages/**/*.{ts,tsx,js,jsx}']
|
|
const RENDERER_IGNORES = ['src/renderer/**/*.test.*', 'src/renderer/**/__tests__/**', 'src/renderer/**/__mocks__/**']
|
|
const boundarySettings = {
|
|
'import-x/resolver-next': [
|
|
createTypeScriptImportResolver({ project: path.join(RENDERER_DIRNAME, 'tsconfig.web.json'), alwaysTryTypes: true })
|
|
]
|
|
}
|
|
// Two independent gates: block1 (layer edges) is enforced as error — Stage 1 cleared it; block2 (sibling pages) stays warn until features-ization.
|
|
const RENDERER_BOUNDARY = 'error'
|
|
const PAGE_SIBLING = process.env.RENDERER_PAGE_SIBLING_ERROR ? 'error' : 'warn'
|
|
|
|
export default defineConfig([
|
|
eslint.configs.recommended,
|
|
tseslint.configs.recommended,
|
|
eslintReact.configs['recommended-typescript'],
|
|
reactHooks.configs['recommended-latest'],
|
|
{
|
|
plugins: {
|
|
'simple-import-sort': simpleImportSort,
|
|
'unused-imports': unusedImports,
|
|
'import-zod': importZod
|
|
},
|
|
rules: {
|
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
'@typescript-eslint/no-explicit-any': 'off',
|
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
|
|
'simple-import-sort/imports': 'error',
|
|
'simple-import-sort/exports': 'error',
|
|
'unused-imports/no-unused-imports': 'error',
|
|
'@eslint-react/no-prop-types': 'error',
|
|
'import-zod/prefer-zod-namespace': 'error'
|
|
}
|
|
},
|
|
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
|
{
|
|
rules: {
|
|
'@typescript-eslint/no-require-imports': 'off',
|
|
'@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
|
|
'@typescript-eslint/no-unused-expressions': 'off',
|
|
'@typescript-eslint/no-empty-object-type': 'off',
|
|
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off',
|
|
'@eslint-react/web-api/no-leaked-event-listener': 'off',
|
|
'@eslint-react/web-api/no-leaked-timeout': 'off',
|
|
'@eslint-react/no-unknown-property': 'off',
|
|
'@eslint-react/no-nested-component-definitions': 'off',
|
|
'@eslint-react/dom/no-dangerously-set-innerhtml': 'off',
|
|
'@eslint-react/no-array-index-key': 'off',
|
|
'@eslint-react/no-unstable-default-props': 'off',
|
|
'@eslint-react/no-unstable-context-value': 'off',
|
|
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off',
|
|
'@eslint-react/hooks-extra/no-unnecessary-use-prefix': 'off',
|
|
'@eslint-react/no-children-to-array': 'off'
|
|
}
|
|
},
|
|
{
|
|
ignores: [
|
|
'node_modules/**',
|
|
'build/**',
|
|
'dist/**',
|
|
'out/**',
|
|
'local/**',
|
|
'tests/**',
|
|
'.yarn/**',
|
|
'.gitignore',
|
|
'.conductor/**',
|
|
'scripts/cloudflare-worker.js',
|
|
'src/main/services/nutstore/sso/lib/**',
|
|
'src/renderer/ui/**',
|
|
'src/renderer/routeTree.gen.ts',
|
|
'packages/**/dist',
|
|
'v2-refactor-temp/**'
|
|
]
|
|
},
|
|
// turn off oxlint supported rules.
|
|
...oxlint.configs['flat/eslint'],
|
|
...oxlint.configs['flat/typescript'],
|
|
...oxlint.configs['flat/unicorn'],
|
|
// Custom rules should be after oxlint to overwrite
|
|
// LoggerService Custom Rules - only apply to src directory
|
|
{
|
|
files: ['src/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'],
|
|
rules: {
|
|
'no-restricted-syntax': [
|
|
process.env.CI ? 'error' : 'warn',
|
|
{
|
|
selector: 'CallExpression[callee.object.name="console"]',
|
|
message:
|
|
'❗CherryStudio uses unified LoggerService: 📖 docs/en/guides/logging.md\n❗CherryStudio 使用统一的日志服务:📖 docs/zh/guides/logging.md\n\n'
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Application lifecycle - all quit-related APIs and events are managed by Application.ts
|
|
{
|
|
files: ['src/main/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: [
|
|
'src/main/core/application/Application.ts',
|
|
'src/main/data/migration/**',
|
|
'src/main/**/__tests__/**',
|
|
'src/main/**/__mocks__/**',
|
|
'src/main/**/*.test.*'
|
|
],
|
|
plugins: {
|
|
lifecycle: {
|
|
rules: {
|
|
'no-direct-quit': {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description:
|
|
'Disallow direct use of quit-related Electron/Node.js APIs. All quit handling is centralized in Application.ts.',
|
|
recommended: true
|
|
},
|
|
messages: {
|
|
restricted:
|
|
'Quit-related APIs and events are managed by the Application lifecycle. Do not use "{{name}}" directly. See docs/en/references/lifecycle/application-overview.md'
|
|
}
|
|
},
|
|
create(context) {
|
|
const RESTRICTED_APP_METHODS = new Set(['quit', 'exit', 'relaunch'])
|
|
const RESTRICTED_APP_EVENTS = new Set(['before-quit', 'will-quit', 'window-all-closed'])
|
|
const RESTRICTED_SIGNALS = new Set(['SIGINT', 'SIGTERM'])
|
|
|
|
return {
|
|
CallExpression(node) {
|
|
const { callee } = node
|
|
if (callee.type !== 'MemberExpression') return
|
|
if (callee.object.type !== 'Identifier') return
|
|
|
|
const obj = callee.object.name
|
|
const prop = callee.property.type === 'Identifier' ? callee.property.name : null
|
|
if (!prop) return
|
|
|
|
// app.quit() / app.exit() / app.relaunch()
|
|
if (obj === 'app' && RESTRICTED_APP_METHODS.has(prop)) {
|
|
context.report({ node, messageId: 'restricted', data: { name: `app.${prop}()` } })
|
|
return
|
|
}
|
|
|
|
// app.on/once('before-quit'|'will-quit'|'window-all-closed', ...)
|
|
if (obj === 'app' && (prop === 'on' || prop === 'once')) {
|
|
const firstArg = node.arguments[0]
|
|
if (firstArg?.type === 'Literal' && RESTRICTED_APP_EVENTS.has(firstArg.value)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'restricted',
|
|
data: { name: `app.${prop}('${firstArg.value}')` }
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
// process.on/once('SIGINT'|'SIGTERM', ...)
|
|
if (obj === 'process' && (prop === 'on' || prop === 'once')) {
|
|
const firstArg = node.arguments[0]
|
|
if (firstArg?.type === 'Literal' && RESTRICTED_SIGNALS.has(firstArg.value)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'restricted',
|
|
data: { name: `process.${prop}('${firstArg.value}')` }
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
rules: {
|
|
'lifecycle/no-direct-quit': 'warn'
|
|
}
|
|
},
|
|
// i18n
|
|
{
|
|
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'module'
|
|
},
|
|
plugins: {
|
|
i18n: {
|
|
rules: {
|
|
'no-template-in-t': {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: '⚠️不建议在 t() 函数中使用模板字符串,这样会导致渲染结果不可预料',
|
|
recommended: true
|
|
},
|
|
messages: {
|
|
noTemplateInT: '⚠️不建议在 t() 函数中使用模板字符串,这样会导致渲染结果不可预料'
|
|
}
|
|
},
|
|
create(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
const { callee, arguments: args } = node
|
|
const isTFunction =
|
|
(callee.type === 'Identifier' && callee.name === 't') ||
|
|
(callee.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier' &&
|
|
callee.property.name === 't')
|
|
|
|
if (isTFunction && args[0]?.type === 'TemplateLiteral') {
|
|
context.report({
|
|
node: args[0],
|
|
messageId: 'noTemplateInT'
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
rules: {
|
|
'i18n/no-template-in-t': 'warn'
|
|
}
|
|
},
|
|
{
|
|
// Bundle guard: the IpcApi zod schema *values* must never enter the renderer
|
|
// bundle. Renderer code may only `import type` from the schema modules.
|
|
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: ['src/renderer/**/*.test.*', 'src/renderer/**/__tests__/**', 'src/renderer/**/__mocks__/**'],
|
|
rules: {
|
|
'@typescript-eslint/no-restricted-imports': [
|
|
'error',
|
|
{
|
|
patterns: [
|
|
{
|
|
group: ['@shared/ipc/schemas', '@shared/ipc/schemas/*'],
|
|
allowTypeImports: true,
|
|
message:
|
|
'Renderer may only `import type` from @shared/ipc/schemas — a value import pulls the entire zod schema set into the renderer bundle.'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
// Boundary guard: the main process and preload must not import renderer code.
|
|
// Cross-process symbols belong in `@shared`; main-only symbols in `src/main`.
|
|
// Both the `@renderer` alias and relative `**/renderer/**` paths are banned; the
|
|
// main i18n catalog now lives in `src/main/i18n`, and tests that need renderer
|
|
// catalog data read it from disk (fs) rather than importing it.
|
|
files: ['src/main/**/*.{ts,tsx,js,jsx}', 'src/preload/**/*.{ts,tsx,js,jsx}'],
|
|
rules: {
|
|
'@typescript-eslint/no-restricted-imports': [
|
|
'error',
|
|
{
|
|
patterns: [
|
|
{
|
|
group: ['@renderer', '@renderer/**', '**/renderer/**'],
|
|
message:
|
|
'Main/preload must not import renderer code. Use `@shared` for cross-process types, or `src/main` for main-only types. See docs/references/shared-layer-architecture.md.'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Renderer boundary block L: layer edges into shared buckets — Zone A (shared→pages/windows) + Zone C (utils impurity).
|
|
// Scoped to shared-bucket files so it never collides with block P on a pages file. Flips to error once A+C clear.
|
|
{
|
|
files: SHARED_BUCKET_FILES,
|
|
ignores: RENDERER_IGNORES,
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
RENDERER_BOUNDARY,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [
|
|
{
|
|
target: [
|
|
'src/renderer/components',
|
|
'src/renderer/hooks',
|
|
'src/renderer/services',
|
|
'src/renderer/utils'
|
|
],
|
|
from: ['src/renderer/pages', 'src/renderer/windows'],
|
|
message: 'Shared buckets must not import pages/windows (reverse layer edge). renderer-architecture.md §7.'
|
|
},
|
|
{
|
|
target: 'src/renderer/utils',
|
|
from: ['src/renderer/components', 'src/renderer/hooks'],
|
|
message: 'utils/ is stateless and may call downward infra (data/ipc) but must not import components/hooks or any higher app layer. renderer-architecture.md §3.'
|
|
},
|
|
// @logger is a §2 primitive that physically lives under services/. `from` uses a glob, so `except` must also glob.
|
|
{
|
|
target: 'src/renderer/utils',
|
|
from: ['src/renderer/services/**/*'],
|
|
except: ['**/LoggerService.ts'],
|
|
message: 'utils/ must not import renderer services (except @logger). renderer-architecture.md §3.'
|
|
},
|
|
...serviceBarrelZones
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Renderer boundary block P: page-targeted edges — B-pw (page→window) + B-pp (page→sibling-page).
|
|
// Scoped to pages files; both share one severity (one no-restricted-paths instance = one severity), held at warn
|
|
// until features-ization clears the sibling-page edges.
|
|
{
|
|
files: PAGE_FILES,
|
|
ignores: RENDERER_IGNORES,
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
PAGE_SIBLING,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [
|
|
{
|
|
target: 'src/renderer/pages',
|
|
from: 'src/renderer/windows',
|
|
message: 'A page must not import a window (reverse edge). renderer-architecture.md §2/§7.'
|
|
},
|
|
...pageSiblingZones,
|
|
...serviceBarrelZones
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// Renderer boundary block B: topic-barrel guard for the importer regions blocks L/P do not cover
|
|
// (windows, routes, data, ipc, workers, …). Its `files` ignore the L and P scopes so it never shares a
|
|
// file with them — avoiding the flat-config last-wins collision noted above. Held at error like block L.
|
|
{
|
|
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: [...RENDERER_IGNORES, ...SHARED_BUCKET_FILES, ...PAGE_FILES],
|
|
plugins: { 'import-x': importX },
|
|
settings: boundarySettings,
|
|
rules: {
|
|
'import-x/no-restricted-paths': [
|
|
RENDERER_BOUNDARY,
|
|
{
|
|
basePath: RENDERER_DIRNAME,
|
|
zones: [...serviceBarrelZones]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
// renderer legacy css var migration warnings
|
|
{
|
|
files: ['src/renderer/**/*.{ts,tsx,js,jsx}'],
|
|
ignores: [
|
|
'src/renderer/**/*.test.*',
|
|
'src/renderer/**/__tests__/**',
|
|
'src/renderer/**/__mocks__/**'
|
|
],
|
|
plugins: {
|
|
'renderer-styles': {
|
|
rules: {
|
|
'no-legacy-css-vars': {
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description:
|
|
'Warn when renderer code references legacy CSS compatibility variables instead of the shared theme contract.',
|
|
recommended: true
|
|
},
|
|
messages: {
|
|
legacyVar:
|
|
'Legacy renderer CSS variable "{{variable}}" is deprecated. Prefer @cherrystudio/ui theme contract variables or Tailwind semantic utilities instead.'
|
|
}
|
|
},
|
|
create(context) {
|
|
function reportIfLegacyCssVar(node, text) {
|
|
const matches = text.matchAll(LEGACY_RENDERER_CSS_VAR_REGEX)
|
|
for (const match of matches) {
|
|
const variable = match[1]
|
|
if (!variable) continue
|
|
context.report({
|
|
node,
|
|
messageId: 'legacyVar',
|
|
data: { variable }
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
Literal(node) {
|
|
if (typeof node.value !== 'string') return
|
|
reportIfLegacyCssVar(node, node.value)
|
|
},
|
|
TemplateElement(node) {
|
|
reportIfLegacyCssVar(node, node.value.raw)
|
|
},
|
|
JSXText(node) {
|
|
reportIfLegacyCssVar(node, node.value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
rules: {
|
|
'renderer-styles/no-legacy-css-vars': process.env.NO_LEGACY_CSS_WARN ? 'off' : 'warn'
|
|
}
|
|
},
|
|
// Schema key naming convention (cache, preferences, paths & IPC route/event keys)
|
|
// Supports both fixed keys and template keys:
|
|
// - Fixed: 'app.user.avatar', 'chat.multi_select_mode'
|
|
// - Template: 'scroll.position.${topicId}', 'entity.cache.${type}_${id}'
|
|
// Template keys must follow the same dot-separated pattern as fixed keys.
|
|
// When ${xxx} placeholders are treated as literal strings, the key must match: xxx.yyy.zzz_www
|
|
{
|
|
files: [
|
|
'src/shared/data/cache/cacheSchemas.ts',
|
|
'src/shared/data/preference/preferenceSchemas.ts',
|
|
'src/main/core/paths/pathRegistry.ts',
|
|
// IPC route/event keys — whole dir so future domains are auto-enforced (see ipc-schema-guide.md).
|
|
'src/shared/ipc/schemas/**/*.ts'
|
|
],
|
|
plugins: {
|
|
'data-schema-key': {
|
|
rules: {
|
|
'valid-key': {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description:
|
|
'Enforce schema key naming convention: namespace.sub.key_name (template placeholders treated as literal strings)',
|
|
recommended: true
|
|
},
|
|
messages: {
|
|
invalidKey:
|
|
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar, scroll.position.${id}). Template ${xxx} is treated as a literal string segment.',
|
|
invalidTemplateVar:
|
|
'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).'
|
|
}
|
|
},
|
|
create(context) {
|
|
/**
|
|
* Validates a schema key for correct naming convention.
|
|
*
|
|
* Both fixed keys and template keys must follow the same pattern:
|
|
* - Lowercase segments separated by dots
|
|
* - Each segment: starts with letter, contains letters/numbers/underscores
|
|
* - At least two segments (must have at least one dot)
|
|
*
|
|
* Template keys: ${xxx} placeholders are treated as literal string segments.
|
|
* Example valid: 'scroll.position.${id}', 'entity.cache.${type}_${id}'
|
|
* Example invalid: 'cache:${type}' (colon not allowed), '${id}' (no dot)
|
|
*
|
|
* @param {string} key - The schema key to validate
|
|
* @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }}
|
|
*/
|
|
function validateKey(key) {
|
|
// Check if key contains template placeholders
|
|
const hasTemplate = key.includes('${')
|
|
|
|
if (hasTemplate) {
|
|
// Validate template variable names first
|
|
const templateVarPattern = /\$\{([^}]*)\}/g
|
|
let match
|
|
while ((match = templateVarPattern.exec(key)) !== null) {
|
|
const varName = match[1]
|
|
// Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore
|
|
if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) {
|
|
return { valid: false, error: 'invalidTemplateVar' }
|
|
}
|
|
}
|
|
|
|
// Replace template placeholders with a valid segment marker
|
|
// Use 'x' as placeholder since it's a valid segment character
|
|
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x')
|
|
|
|
// Template key must follow the same pattern as fixed keys
|
|
// when ${xxx} is treated as a literal string
|
|
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
|
if (!fixedKeyPattern.test(keyWithoutTemplates)) {
|
|
return { valid: false, error: 'invalidKey' }
|
|
}
|
|
|
|
return { valid: true }
|
|
} else {
|
|
// Fixed key validation: standard dot-separated format
|
|
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
|
if (!fixedKeyPattern.test(key)) {
|
|
return { valid: false, error: 'invalidKey' }
|
|
}
|
|
return { valid: true }
|
|
}
|
|
}
|
|
|
|
return {
|
|
TSPropertySignature(node) {
|
|
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
|
const key = node.key.value
|
|
const result = validateKey(key)
|
|
if (!result.valid) {
|
|
context.report({
|
|
node: node.key,
|
|
messageId: result.error,
|
|
data: { key }
|
|
})
|
|
}
|
|
}
|
|
},
|
|
Property(node) {
|
|
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
|
// Keys inside a `z.*(...)` object literal are zod data-field names
|
|
// (e.g. z.object({ 'content-type': ... })), not route/schema keys, so the
|
|
// namespace.action convention does not apply — skip them. Anchored on the
|
|
// zod namespace `z`, this covers z.object/z.strictObject/etc. while leaving
|
|
// Object.freeze(...) registries (pathRegistry.ts) still validated.
|
|
const enclosing = node.parent
|
|
if (
|
|
enclosing?.parent?.type === 'CallExpression' &&
|
|
enclosing.parent.callee?.type === 'MemberExpression' &&
|
|
enclosing.parent.callee.object?.type === 'Identifier' &&
|
|
enclosing.parent.callee.object.name === 'z'
|
|
) {
|
|
return
|
|
}
|
|
const key = node.key.value
|
|
const result = validateKey(key)
|
|
if (!result.valid) {
|
|
context.report({
|
|
node: node.key,
|
|
messageId: result.error,
|
|
data: { key }
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
rules: {
|
|
'data-schema-key/valid-key': 'error'
|
|
}
|
|
}
|
|
])
|