Files
novalon-website/docs/development/quality-gates.md
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

255 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 代码质量门禁
## 概述
项目配置了自动化质量门禁,确保代码提交前通过所有质量检查。
## 质量检查
### 1. 代码风格检查
**工具**: ESLint(flat config:`eslint.config.mjs`)
**检查时机**:
- pre-commit hook:`.husky/pre-commit` → `npx lint-staged` → `.lintstagedrc.json` 仅对 `*.{js,jsx,ts,tsx}` 执行 `eslint --fix`
- `npm run lint`(全仓)、`npm run test:all`
- CI「🔍 代码质量检查」阶段(`Jenkinsfile`)
**检查内容**:
- 代码语法错误
- 代码风格规范
- 自动可修复项的格式化(仅暂存文件,pre-commit 阶段)
**通过标准**: **0 error**。warning **不判红**——这是本项目的显式裁定(见 `CONTEXT.md`「lint warning 分级与门禁判定口径」);把 warning 清零不是门禁前置条件,但**不得**为凑绿而把它们调成 `off`。
**warning 数量口径(本文件为版本库内的权威记录位)**:2026-09-23 **第二周期**最终树实测 `npm run lint` ⇒ **EXIT=0,`✖ 104 problems (0 errors, 104 warnings)`**,分项相加 = 104:**55** `no-console` / **25** `@typescript-eslint/no-explicit-any` / **11** `react-hooks/set-state-in-effect` / **10** `@next/next/no-img-element` / 各 1 `@next/next/no-sync-scripts`、`@next/next/no-html-link-for-pages`、`Unused eslint-disable directive`(死抑制)。同日**第一周期**记录的 105 条与本树只差 1,且差值唯一归因为 `set-state-in-effect` **12→11**(后台编辑器的"挂载期消费守卫"那个效果被删)——**没有任何一项是本周期新增**。该计数**随树漂移**(上一轮 chain4/chain6 为 106/56),引用时必须同时给复算命令,不得当作常量:
```bash
npm run lint # 看末行 ✖ N problems
npm run lint 2>&1 | grep -oE '[a-z@/_-]+/[a-z-]+$' | sort | uniq -c | sort -rn # 分项
```
> ⚠ 不要再以 `AGENTS.md` §5 为该数字的出处:`AGENTS.md` 被 `.gitignore` 有意排除、从未被 git 跟踪、并由 `next dev` 再生(验收 N-16),其内容不可审、不可回滚、可能随再生丢失,只当本地便签。
**已知例外(须保留原因)**:`eslint.config.mjs` 的按路径 override 只在三类位置降档——`ruixin/e2e-tests`(`e2e/**/*.ts` 关 `no-console`,`:65-71`)、`ruixin/unit-tests`(`**/*.test.{ts,tsx}`、`**/__tests__/**` 关 `@typescript-eslint/no-explicit-any`,`:73-79`)、`ruixin/performance-tests`(`tests/performance/*.js`,`:81`)。**`prisma/seed.ts` 不在任何 override 内**,它的 `no-console` 仍是 warning 形态存在(占 §5 计数的大头),属「种子脚本进度日志」的有意容忍项,而非配置豁免(其数量口径见本节上方的 warning 分项表)。
### 2. 提交信息规范
**工具**: commitlint
**检查时机**: commit-msg hook
**检查内容**:
- 提交信息格式
- 提交类型合法性
**通过标准**: 符合Conventional Commits规范
**提交类型**:
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 重构
- `perf`: 性能优化
- `test`: 测试相关
- `chore`: 构建/工具相关
- `revert`: 回滚提交
- `build`: 构建相关
- `ci`: CI/CD相关
**提交信息格式**:
```
<type>(<scope>): <subject>
<body>
<footer>
```
**示例**:
```
feat(auth): add JWT authentication
Implement JWT-based authentication with:
- Token generation
- Token validation
- Refresh token mechanism
Closes #123
```
### 3. 代码覆盖率检查
**工具**: Jest
**检查时机**: 手动运行或CI/CD
**检查内容**:
- 单元测试覆盖率
- 分支覆盖率
- 函数覆盖率
- 行覆盖率
- 语句覆盖率
**通过标准**(阈值单一真源:`config/test/jest.config.js` 的 `coverageThreshold`,根 `jest.config.js` 仅转发):
- 全局: statements / lines / functions ≥ 75%,branches ≥ 82%
- 目录级棘轮按各目录实测值下调 3–5 个点设定,只允许上调
- **实测值的权威记录位就是本节**(`README.md` / `CLAUDE.md` 不再复制百分比,只指向这里;`config/test/jest.config.js` 顶部注释只是写死的快照,会滞后于树,见下方告警)
- 实测(**2026-09-23 本树复跑** `npm run test:coverage` ⇒ **134 suites / 1697 tests,`EXIT=0`、0 条 threshold 告警**):
statements 85.66% / branches 87.49% / functions 80.67% / lines 85.66%
- 目录级:`components/content` 100×4 · `components/sections` 73.69 / 90.71 / 73.8 / 73.69 · `lib/cms` 91.93 / 92.77 / 92.68 / 91.93
- ⚠ 全局实测已高出门限 5.67–10.66 个点 ⇒ 棘轮当前**偏松**(未达本文件自订的「实测 −3~5pp」)。上调是独立动作,须单列提交,不夹带功能变更
- ⚠ `config/test/jest.config.js:28-29` 的注释把 **85.56 / 86.86 / 80.59 / 85.56** 与 `components/sections` 的 87.06 挂在「134 suites / 1697 tests」名下,属**跨轮错配**:85.56 那一组属于更早的 **132 suites / 1677 tests** 树(见 `docs/acceptance/2026-09-23-gates/final-verdict.md` §1),134/1697 轮次当时的全局值是 85.59 / 87.5 / 80.67 / 85.59(同文件 N-31④)。该文件不在本次修订范围内,故此处以注释纠偏说明代替改码;引用覆盖率时**以上述本树复跑值为准**,并同时给出复算命令:`npm run test:coverage 2>&1 | grep -E "All files|Test Suites:|^Tests:"`
### 4. 类型检查
**工具**: TypeScript(`tsc --noEmit`)
**检查时机**: `npm run type-check`、`npm run test:all`、CI「🔍 代码质量检查 → TypeScript」阶段。**不在 pre-commit**——`.lintstagedrc.json` 只跑 `eslint --fix`,故本地提交不会拦下类型错误,推送前须自行跑 `type-check`。
**检查内容**:
- 类型错误
- 类型推断
**通过标准**: 无类型错误
### 5. 可访问性静态门禁(`check:a11y`)
**工具**: 三个自研 tsx 脚本(`package.json` 的 `check:a11y` 串起三者;`Jenkinsfile`「♿ 可访问性门禁」阶段逐条调用同一组,全分支执行)
| 脚本 | 命令 | 输入源 | 断言 |
|---|---|---|---|
| 令牌对比度 | `npm run check:contrast` | **只读** `src/app/globals.css` 的两主题令牌表(`scripts/utils/check-color-contrast.ts:175`),不启服务 | 声明的令牌配对(含 alpha 组、暗色)达 AA;缺令牌即红,不允许静默跳过 |
| 标题层级 | `npm run check:headings` | 存在 `dist/standalone/server.js` 时**直起 standalone**,产物缺失才回退自起 `npm run preview` 并打印告警(`scripts/utils/check-heading-hierarchy.ts:52-67`,验收 N-24③),随后 HTTP 抓渲染页 | 每页恰好 1 个 h1、层级不跳跃、无空标题;进程组回收见 `docs/lessons-learned.md` §5.23 |
| 双通道红 | `npm run check:brand-token` | 静态扫描 `src` 源码 | 品牌红**文字**必须走 `text-brand-ink*`,禁 `text-[var(--color-brand)]` |
**通过标准**: 三条全绿。**已知测不到的面**:axe 与 `check:contrast` 都忽略 `:hover`,所以半透明 hover 底色要手工合成算对比度(`CONTEXT.md`「品牌色令牌的语义拆分」);「计数 = 0」型门禁必须同时断言分母(`CONTEXT.md`「门禁断言的有效性口径」)。
#### 5.1 动效契约门禁 `npm run check:motion`(N-29 的收口,**尚未并入 `check:a11y`**)
验收 N-29(`docs/acceptance/2026-09-23-gates/final-verdict.md` §3)记录:`check:a11y` 只等于 contrast + headings + brand-token,「没有任何脚本约束时长与缓动曲线」,而 `CONTEXT.md:73-85`「动效设计四原则」是有出处的硬约束。`scripts/utils/check-motion-constraints.ts` 把它变成可判定门禁:
| 规则 | 断言 | 出处 |
|---|---|---|
| R1 `token-band` | `--transition-instant` == 100ms(反馈档);`--transition-fast` / `--transition-normal` ∈ 180–280ms(入场档,令牌名由原文点名) | `CONTEXT.md:76` |
| R2 `entrance-cap` | 一切**动效时长** ≤ 700ms:CSS `transition*:` / `animation*:` 声明值(含经 `var(--transition-*)` 解析后的值)、framer-motion `transition` 对象内的 `duration: <秒>`、Tailwind `duration-NNN`(裸数字按 ms 计)、`tailwind.config.js` 的 `animation` 简写串。豁免:`infinite` 循环动画、`*-delay`(延迟属 `:78` stagger 口径,不判) | `CONTEXT.md:82` |
| R3 `css-transition-token` | transition 声明的时长必须走 `var(--transition-*)`,裸 ms/s 字面量与悬空 `var()` 引用均判红(豁免 `prefers-reduced-motion` 的 `!important` 硬开关) | `CONTEXT.md:76` |
| R4 `easing-palette` | 写死的贝塞尔曲线必须等于令牌层已声明的某条 —— **允许集合从 CSS `--ease-*` 声明里解析,不在脚本里硬编码**;另钉住基准 `--ease-ink == [0.22, 1, 0.36, 1]` | `CONTEXT.md:77` |
**退出码**:0 无违规 / 1 有违规 / **2 未能度量**(目标目录或令牌文件缺失、扫到的源码文件数为 0、`--ease-*` 或 `--transition-*` 一个都没解析到)。摘要行始终打印分母(扫描文件数 / CSS 声明数 / 时长字面量数 / 贝塞尔字面量数),**空扫描绝不读作干净** —— 这是 N-22 / N-24 / A-6「永不可失败的门禁」的直接防线。无 `warn` 代替 `fail` 的分支。
**自证伪**:`npm run check:motion:test`(19 例,`scripts/utils/__tests__/check-motion-constraints.test.ts`)对每条规则做**双向**断言 —— 合规夹具判 0(防常红)+ 违规夹具判该规则红(防常绿),并含一条元测试:四条规则的触发集合必须恰好等于 `MOTION_RULES` 声明的全集(任一规则变得不可触发即红)。该测试跑在 `scripts/` 下,故需 `--roots` 显式指定(jest 默认 `roots` 是 `src`),不在 `test:unit` / `test:coverage` 的口径内。
**当前树状态(2026-09-30 实测)**:`npm run check:motion` → **EXIT=1,23 处违规 / 13 个文件**(R1 0、R2 1、R3 2、R4 20;分母 = 262 个源码文件 · 46 条 CSS 声明 · 290 个时长字面量 · 66 条贝塞尔字面量 · 6 个 `--transition-*` 令牌 · 6 个 `--ease-*` 令牌)。主体是 `[0.16, 1, 0.3, 1]`(17 处)与 `[0.25, 1, 0.5, 1]`(3 处)两条**未在令牌层声明**的曲线,另有 `--transition-gentle: 1000ms` 超上限、`globals.css:1457` 与 `src/lib/constants/design-system.ts:76` 的裸时长字面量。因此**本轮只登记独立脚本、不并入 `check:a11y`**(并入即把伞下门禁做成永久红);违规修完后把 `check:motion` 追加进 `package.json` 的 `check:a11y` 串即可,规则本身不得为转绿而放宽。
**已接地的动态 axe 门禁(2026-09-23)**:`check:axe` / `check:axe:routes` 的脚本已落到常驻位置 `scripts/accessibility/{axe-node-count,crawl-routes}.mjs`(原 `docs/acceptance/2026-09-21-axe/` 的一次性 harness 平移而来,证据 JSON 仍留原目录,最终树 34 路由 × 4 组合 `passed=true`)。判定从「打印 PASSED」升级为**退出码**:0 通过 / 1 判红(任一引擎×主题组合的 violation 节点 > 0、主题或底色不匹配、三条规则级规则的分母不闭合、扫描中断)/ 2 清单缺失或解析出 0 条。CI 接线在 `Jenkinsfile`「♿♿ 全站 axe 节点计数」阶段(仅 main,复用上一阶段的 `dist/standalone`,按 `node dist/standalone/server.js` 起服务,`set -e`、无 `|| echo`),因此它是**真实的自动化门禁**而非可选脚本;但它不在 `check:a11y` / `test:all` 伞下(那三条是零服务依赖的静态检查,axe 轮需要生产服务与两个浏览器引擎)。复跑口径与静态资源装配要求见 `docs/testing.md`「axe 全站节点计数门禁(验收 §8-⑤)」一节。
### 6. E2E 目标口径(dev 与产物不是一回事)
| 命令 | webServer | 覆盖含义 |
|---|---|---|
| `npm run test` / `test:functional` / `test:e2e` | `npm run dev` | 日常回归。**GA4 的 4 个 `@critical` 用例 × 4 project = 16 个实例是 skipped**(GA ID 只在 `.env.production`,dev 下不注入 gtag,`test.skip` 触发)。证据 `docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json` |
| `npm run test:e2e:prod` | `E2E_TARGET=production` ⇒ `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js`(`e2e/playwright.config.ts:132-135`) | **唯一**真正断言 GA4(生产响应头不在此集合,属 `@security`)/ 预渲染与 ISR 的口径;用例集是 `@smoke\|@critical\|@journey` × 4 project,`reuseExistingServer` 在此目标下为 false(`:139`)。产物是 `output: 'standalone'`(`next.config.mjs:44`),Next 16 下 `next start` 明确不受支持,故不能写成 `npm run start`;harness 不代跑构建,须先 `npm run build` 并按 `Jenkinsfile:353-355` 把 `dist/static` 与 `public` 拷进 `dist/standalone/`(standalone 不自含这两者,缺了 `/_next/static/**` 全 404) |
| `Jenkinsfile`「🌐 E2E 测试」(仅 main) | build + `test:e2e:prod` | CI 在 main 上执行产物口径;功能分支本地跑 `npm run test` 不覆盖它 |
**结论**:以 `npm run test` 的绿灯宣称"GA4/生产头已验证"属口径错误;引用时必须指明目标。
### 7. 一键门禁 `npm run test:all`
`type-check` → `lint` → `test:coverage` → **`test:integration:real`** → **`check:a11y`** → `test:e2e:fast` → `test:security:headers`(`package.json:48` 原文即此七段,勿凭记忆省略 `test:integration:real`)。注意最后一项 E2E 仍是 **dev 目标**(`@smoke|@critical`),GA4 的 16 个实例同样 skipped,故 `test:all` 绿之后仍需按 §6 补 `test:e2e:prod`。
## 提交与 PR 流程(PR-First)
`dev` / `main` 只接收来自功能分支的 PR,执行顺序固定:**同步 dev → rebase → push → PR 门禁 → rebase 合并**。
```bash
git fetch origin dev && git rebase origin/dev # 必须以 origin/dev 为准,本地 dev 可能已陈旧
git push -u origin <feature-branch> # rebase 改写哈希后仅用 --force-with-lease,禁裸 --force
bash scripts/check-pr-checklist.sh <PR描述文件> # 未通过不得创建 PR
```
- PR 模板:`.gitea/PULL_REQUEST_TEMPLATE.md`(三节:全链路检查 / 测试分层检查 / 质量门禁)。
- 门禁校验:模板三节完整 + checklist 子项 ≥ 20 + 描述文件所有子项已勾选;不适用项**仍须勾选**并注明 `N/A:理由`。
- 功能分支合并方式统一选 Gitea 的 **Rebase**(保持线性历史),不选 Create merge commit。
- 破坏性/共享状态动作(seed 重跑、DB 写入、force push、合并、删分支、部署)须单独取得授权,不夹带在常规变更里。
> 注:`AGENTS.md` 被 `.gitignore` 排除(由 `next dev` 再生),本节是该流程在版本库中的权威副本。
## 如何绕过质量门禁
⚠️ **警告**: 仅在紧急情况下绕过质量门禁
### 绕过pre-commit hook
```bash
git commit --no-verify -m "message"
```
### 绕过commit-msg hook
```bash
git commit --no-verify -m "message"
```
### 绕过所有hooks
```bash
git commit --no-verify -m "message"
```
## 故障排查
### ESLint错误
**问题**: pre-commit hook因ESLint错误失败
**解决方案**:
1. 查看错误详情
2. 修复代码或配置
3. 运行 `npm run lint` 检查
4. 重新提交
### commitlint错误
**问题**: commit-msg hook因提交信息格式错误失败
**解决方案**:
1. 检查提交信息格式
2. 使用正确的提交类型
3. 重新提交
### 覆盖率不达标
**问题**: 覆盖率检查失败
**解决方案**:
1. 查看覆盖率报告
2. 补充测试用例
3. 重新提交
## 持续改进
### 提高覆盖率阈值
随着项目发展,逐步提高覆盖率阈值(棘轮只允许上调,调整前须附 `npm run test:coverage` 实测值):
- 当前(2026-09-21): 全局 statements 75% / branches 82%
- 下一阶段目标: 全局 statements 80% / branches 85%
### 添加更多质量检查
未来可以添加:
- 复杂度检查
- 重复代码检查
- 安全漏洞扫描
- 依赖漏洞检查
## 参考资料
- [Conventional Commits](https://www.conventionalcommits.org/)
- [ESLint文档](https://eslint.org/)
- [Jest文档](https://jestjs.io/)
- [Husky文档](https://typicode.github.io/husky/)
- [lint-staged文档](https://github.com/okonet/lint-staged)