chore(quality): 接入 ESLint 与 GitLab CI,开启严格未用检查,构建期设计令牌守卫

- 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 产物留存一周
This commit is contained in:
2026-09-22 23:45:23 +08:00
parent f71f29b4cb
commit e6373d3c9a
6 changed files with 3001 additions and 4 deletions
+104
View File
@@ -0,0 +1,104 @@
# Profeto CI —— 首次引入自动化质量防线。
#
# 背景:此前仓库无任何 CI。唯一的测试文件 frontend/src/lib/http.test.ts
# 从未被自动执行过,类型检查也仅靠本地手跑 tsc。本流水线把
# 「类型检查 + 单元测试 + 构建」固化为 MR 门禁。
#
# 复用项目自带的 Docker 镜像(与 docker-compose.yml 同源),
# 避免 CI 环境与本地/线上不一致。
stages:
- verify
default:
image: node:22-alpine
# ── 前端:Lint ────────────────────────────────────────────────────
# 拦截「机器能看出来、人容易漏掉」的问题:幻影 CSS 变体、
# 硬编码色值、渲染期间副作用、未使用符号。
# 当前基线为 0 error / 56 warning,故本 job 会真实失败于新增 error。
frontend-lint:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm lint
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:类型检查 ────────────────────────────────────────────────
frontend-typecheck:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm typecheck
# 缓存 pnpm store,避免每次 MR 重新下载依赖
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:单元测试 ────────────────────────────────────────────────
# 这是 http.test.ts 第一次被自动化调用。它覆盖 HTTP 层的
# method/body/Content-Type 组装与 /health 免前缀豁免 —— 后者的
# 回归会导致管理后台右上角永远显示「系统异常」。
frontend-test:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm test
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:生产构建 ────────────────────────────────────────────────
# 独立于 typecheck:构建会暴露类型检查覆盖不到的问题
# (Tailwind 配置错误、资源缺失、chunk 拆分失败等)。
frontend-build:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm build
artifacts:
name: "frontend-dist-$CI_COMMIT_SHORT_SHA"
paths:
- frontend/dist/
expire_in: 1 week
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+236
View File
@@ -0,0 +1,236 @@
/**
* 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 }
+12 -2
View File
@@ -5,8 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build && sh scripts/verify-tokens.sh",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
"verify:tokens": "sh scripts/verify-tokens.sh"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
@@ -14,13 +18,19 @@
"react-router-dom": "^6.30.6" "react-router-dom": "^6.30.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.70.1",
"vite": "^5.3.4" "vite": "^5.3.4"
} }
} }
+2601
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# 设计令牌完整性守卫 —— 确认关键颜色类真的生成了 CSS。
#
# 背景:把商标色改为 CSS 变量时踩过一个坑 —— 变量若写成完整十六进制
# (#9E1B1B)而非 RGB 三元组(158 27 27),Tailwind 的透明度修饰符
# (bg-press-wash/60)会**静默不生成任何 CSS**。构建通过、类型检查通过、
# 控制台无警告,只是样式没了。这类问题只能靠检查产物发现。
#
# 用法: pnpm build && sh scripts/verify-tokens.sh
set -e
CSS=$(ls dist/assets/*.css 2>/dev/null | head -1)
if [ -z "$CSS" ]; then
echo "✗ 未找到构建产物 CSS,请先执行 pnpm build"
exit 1
fi
fail=0
check() {
if grep -q -F -- "$1" "$CSS"; then
echo "$1"
else
echo "$1 缺失"
fail=1
fi
}
echo "校验设计令牌在构建产物中的存在性:"
check '.bg-press{'
check '.text-ink-900{'
check '.bg-paper-50{'
check '.border-press{'
check 'press-wash\/60'
check '.masthead-rule{'
check '--press:'
if [ "$fail" -ne 0 ]; then
echo ""
echo "设计令牌校验失败。"
echo "最常见原因:tailwind.config.js 中颜色值未写成"
echo " 'rgb(var(--x) / <alpha-value>)'"
echo "而写成了完整十六进制或 'var(--x)' —— 前者会丢掉透明度修饰符。"
exit 1
fi
echo "设计令牌校验通过"
+2 -2
View File
@@ -12,8 +12,8 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": true,
"noUnusedParameters": false, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],