/** * ESLint 配置 —— 工程的自动化质量防线。 * * 本文件的核心目的不只是「常规 lint」,而是把审查报告里 * 「只能靠人肉 review 发现」的几类问题固化成机器规则: * * 1. 幻影变体:此前 `btn-ghost` 被使用但从未在 CSS 中定义, * 样式静默失效,类型检查和构建都不报错。 * 2. 裸控件:components/ui 已提供 Button/Input/Select 基元, * 但页面里仍有人直接写原生标签 + 手拼 className, * 导致样式与无障碍行为再次分叉。 * 3. 硬编码色值:设计令牌是唯一颜色来源,十六进制字面量 * 会让改令牌时产生「漏网之鱼」。 * * 规则原则:宁可少而准,不要多而吵。无法修复的告警只会被忽略。 */ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' /** * CSS 变体类名总表 —— index.css 的 @layer components 里定义的全部类。 * * 分两组,因为二者的处置方式完全不同: * * - `PRIMITIVE_CLASSES`:已有组件基元等价物(Button / Input / Select / Tabs)。 * 页面里**手拼**这些类名 = 绕过基元,样式与无障碍行为会再次分叉。 * 这类用法由 `no-restricted-syntax` 报错拦截。 * * - `COMPOSITE_CLASSES`:组合型排版类(类似 Tailwind 工具类), * 本就设计成裸用(如 `className="section-head mb-2"`)。 * 它们没有、也不该有组件基元,因此不拦截。 * * 之所以要专门拦「单独出现」而非子串匹配:因为 `btn` 是 `btn-solid` * 的前缀,`tab` 是 `tab-on` 的前缀。只有被当作独立 token 使用时才是问题。 */ const PRIMITIVE_CLASSES = [ // Button 基元覆盖 'btn', 'btn-sm', 'btn-solid', 'btn-outline', 'btn-danger', 'btn-ghost', // Input / Select 基元覆盖 'field', // Tabs 基元覆盖 'tab', 'tab-on', ] const COMPOSITE_CLASSES = [ 'section-head', 'skeleton', 'empty-state', 'empty-state-title', 'empty-state-sub', 'empty-state-action', 'error-banner', 'error-banner-title', 'error-banner-detail', 'nav-icon', 'masthead-rule', 'font-brush', ] /** 保留导出,兼容既有引用;内容为全部组件类名。 */ const CSS_VARIANT_CLASSES = [...PRIMITIVE_CLASSES, ...COMPOSITE_CLASSES] /** * 匹配「className 值里单独出现的某个基元类名」的正则。 * * 只匹配独立 token(前后必须是空白或字符串边界),避免两类误判: * - `btn-solid` 里的 `btn`(前缀子串,合法,由 Button 基元自己输出) * - `searchParams.get('tab')` 这类**非样式**字符串 * —— 这正是不能用裸正则扫全量 Literal 的原因(第一版曾误报)。 * * 该正则只配合下面的 AST 选择器使用: * `JSXAttribute[name.name="className"] Literal[...]` 把匹配范围 * 严格限定在 className 属性值内,字符串里出现同名 token 不再误伤。 */ function barePrimitivePattern() { const names = PRIMITIVE_CLASSES.join('|') return new RegExp(`(^|\\s)(${names})(?=\\s|$)`) } export default tseslint.config( { // 只检查 TS/TSX。CSS 由 Tailwind/PostCSS 管线负责, // 交给 ESLint 解析只会得到 "Declaration expected" 噪声。 // 配置类文件自身不参与 lint(它们是规则的声明方,不是被约束方)。 ignores: ['dist', 'node_modules', '*.config.js', '*.config.mjs', '*.config.ts'], }, js.configs.recommended, ...tseslint.configs.recommended, // ── 全局:浏览器环境 ──────────────────────────────────────────── { files: ['src/**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2022, globals: { ...globals.browser, ...globals.es2022, }, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, // ── hooks 规则调整 ─────────────────────────────────────────── // `set-state-in-effect`:加载类 effect(挂载时发起请求 → 回调里 // setState)是 React 官方文档明确认可的模式,该规则在此场景下 // 误报率过高。当前有 19 处这类写法,逐条改写属于行为等价的 // 大范围重构,不适合混在本次质量修复里。降级为 warn 保留可见性。 // 注:真正的「渲染期间副作用」已由 react-hooks/purity 单独拦下 // (Collection.tsx 里的 Date.now() 即被它捕获并已修复)。 'react-hooks/set-state-in-effect': 'warn', // ── 死代码 ─────────────────────────────────────────────────── // tsconfig 里 noUnusedLocals/noUnusedParameters 是关的 // (存量太多,一次性打开会淹没信号)。这里先用 ESLint 的 // 同型规则,允许下划线前缀显式豁免,便于渐进清理。 '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], // ── 类型安全 ───────────────────────────────────────────────── // `any` 在数据访问层是真实的类型漏洞来源(dal.ts 曾有 14 处)。 // 用 warn 而非 error:存量需要分批处理,但不该被遗忘。 '@typescript-eslint/no-explicit-any': 'warn', // 空接口等价于无约束类型参数,通常是想写 type 而非 interface '@typescript-eslint/no-empty-object-type': 'warn', // ── React ──────────────────────────────────────────────────── // 本仓库是纯 SPA(无 RSC、无服务端渲染),Fast Refresh 的粒度为 // 整个模块。兼容壳文件(admin/components.tsx、matches/ui.tsx)与 // 混装常量+组件的文件会命中此规则,但它们本就是刻意保留的 // 再导出层,热更新退化不影响开发体验。关闭以免噪声掩盖真问题。 'react-refresh/only-export-components': 'off', }, }, // ── 组件基元层:自身必然使用原生标签,豁免相关规则 ────────────── { files: ['src/components/ui/**'], rules: { 'react-refresh/only-export-components': 'off', }, }, // ── 禁止硬编码设计令牌色值 + 禁止手拼组件基元类名 ──────────────── { files: ['src/**/*.{ts,tsx}'], // 组件基元层自身必然要写这些类名 —— 它是唯一的合法使用点 ignores: ['src/components/ui/**'], rules: { 'no-restricted-syntax': [ // error 级:两类问题(硬编码色值、手拼基元类名)存量均已清零, // 此后任何新增都应在提交前就地修正,CI 可直接阻断。 'error', { selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]', message: '禁止硬编码颜色字面量。请使用设计令牌(press/ink/paper 等)或 CSS 变量。', }, { selector: 'TemplateElement[value.raw=/rgba?\\(/]', message: '禁止在模板字符串中硬编码 rgb/rgba 颜色。请使用设计令牌或 CSS 变量。', }, // ── 手拼基元类名 ──────────────────────────────────────────── // error 级:这类写法会让组件层形同虚设,且样式/无障碍分叉 // 只在运行时显形,类型检查完全沉默。合法写点(components/ui) // 已被本块的 ignores 排除。 // // 用 JSXAttribute 选择器把范围钉死在 className 属性值上: // 裸 `Literal[...]` 会误伤 `searchParams.get('tab')` 这类 // 恰好含同名 token 的普通字符串。两种写法分别覆盖: // className="field w-full" → String Literal // className={`btn ${x ? 'btn-solid' : ''}`} → TemplateLiteral { selector: `JSXAttribute[name.name="className"] Literal[value=/${barePrimitivePattern().source}/]`, message: '禁止手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的 Button / Input / Select / Tabs 基元 —— 它们统一了变体与无障碍行为。', }, { selector: `JSXAttribute[name.name="className"] TemplateElement[value.raw=/${barePrimitivePattern().source}/]`, message: '禁止在模板字符串中手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的基元组件。', }, ], }, }, // 色值豁免:调色板与主题定义是颜色「来源」,不是「使用点」 { files: [ 'src/index.css', 'tailwind.config.js', 'src/components/ui/**', ], rules: { 'no-restricted-syntax': 'off', }, }, // ── 测试文件 ──────────────────────────────────────────────────── { files: ['src/**/*.test.{ts,tsx}'], languageOptions: { globals: { ...globals.node, }, }, rules: { '@typescript-eslint/no-explicit-any': 'off', }, }, ) // 导出变体列表供后续规则扩展使用,避免魔法字符串散落 export { CSS_VARIANT_CLASSES }