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

16 KiB
Raw Blame History

代码质量门禁

概述

项目配置了自动化质量门禁,确保代码提交前通过所有质量检查。

质量检查

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),引用时必须同时给复算命令,不得当作常量:

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 合并。

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

git commit --no-verify -m "message"

绕过commit-msg hook

git commit --no-verify -m "message"

绕过所有hooks

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%

添加更多质量检查

未来可以添加:

  • 复杂度检查
  • 重复代码检查
  • 安全漏洞扫描
  • 依赖漏洞检查

参考资料