- eslint.config.mjs(flat config):react-hooks/react-refresh/tseslint;自定义 no-restricted-syntax 规则禁止 className 手拼基元类名(error 级,JSXAttribute 选择器精确限定,探针双向验证)与硬编码色值 - tsconfig:noUnusedLocals/noUnusedParameters 置 true - package.json 新增 typecheck/lint/test/verify:tokens 脚本;build 串联令牌校验(verify-tokens.sh 防止 <alpha-value> 类静默失效回归);pnpm-lock.yaml 入库供 CI --frozen-lockfile - .gitlab-ci.yml:lint/typecheck/test/build 四作业,MR 与默认分支触发,dist 产物留存一周
237 lines
9.7 KiB
JavaScript
237 lines
9.7 KiB
JavaScript
/**
|
|
* 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 }
|