1. 背景与问题
-
数据契约类回归无人兜底:维度映射表里一处重复 key、漏注册,构建不报错、页面不一定立刻出错,直到线上才暴露。
-
测试与需求对不上:即使补了测试,也难以回答「这个需求测没测、改动有没有跑偏、有没有夹带无关改动」------测试、代码、需求之间没有可机器校验的挂钩。
本方案只解决「有没有测、测的和需求对不对得上、改动有没有超出需求」
2. 目标
目标
-
G1 单测地基:落地一套可运行的 vitest 单测环境
-
G2 开发流程约束:用 superpowers 开发 feat 需求时,强制先提供需求文档、按 TDD 写带标签的单测。
-
G3 MR 需求对齐门禁 :提 MR 到 dev / uat / master 时,对
feat/*源分支自动跑单测,并由 AI 比对「需求文档 × 代码 diff × 测试报告」,合并成一条 MR 评论输出。
3. 总体架构

两段约束,共享「分支名 → TAPD ID」寻址:
-
开发阶段(本地,G2) :superpowers 流程里强制先有需求文档、按 TDD 写带
[TAPD-ID]标签的单测。靠项目 CLAUDE.md 规则引导,Claude Code hook 卡死跳步。 -
MR 阶段(CI,G3) :门禁在单 job 内按「准备 → 审查 → 发布」三层执行:
verify.mjs确定性收集数据,verify-specskill 做 AI 比对,发布层发一条 MR 评论。机制与团队已有的merge-request-review同构。设计要点:
-
一个 ID 串起全链路 :分支名 提取 TAPD ID,需求文档按 ID 存(
.superpowers/sdd/{ID}.md),测试按 ID 打标。需求 / 测试 / 改动之间无歧义。 -
确定性优先,LLM 兜底:能算出来的(分支过滤、diff、跑测试、指纹去重、缺需求的软提醒)全部由脚本完成;LLM 只做「读懂需求、比对改动」这一件只有它能做的事。
-
一个门禁,一条评论:先跑真实 vitest(保留抓数据契约类 bug 的能力),再让 AI 读需求 + diff + 测试报告,合并成一条评论。
4. 开发流程约束(superpowers)
不改 superpowers 插件本身(插件升级会覆盖),在 项目侧叠加约束。superpowers 的 skill 明确「用户偏好覆盖默认行为」,项目 CLAUDE.md 的规则会被遵循。
| 约束 | 规则 | 落点 |
|---|---|---|
| 先有需求文档 | feat 分支开工时,若 .superpowers/sdd/{TAPD_ID}.md 不存在,先让用户粘贴需求文本/截图并存入该路径,再写代码 |
CLAUDE.md 规则 + PreToolUse hook |
| 写代码配单测 | 走 superpowers 的 test-driven-development,先写失败测试;测试用 describe('[TAPD-{ID}] ...') 打标,就近放在被测文件旁 |
CLAUDE.md 规则 + Stop hook |
两个 hook 注册在项目 .claude/settings.json,脚本在 .claude/hooks/,随仓库提交,团队拉代码后重开会话即生效。仅对带 TAPD ID 的 feat/* 分支生效,hotfix / 工具分支放行:
| hook | 拦截条件 | 放行情况 |
|---|---|---|
PreToolUse(Edit / Write / MultiEdit) |
要改 src/ 下的业务源码,且 .superpowers/sdd/{ID}.md 不存在 → exit 2,提示先提供需求 |
测试文件、子模块、src/ 以外的文件(包括需求文档本身) |
Stop |
本分支(已提交 + 工作区 + untracked)改了 src/ 业务源码,且 src/ 下没有带 [TAPD-ID] 标签的测试 → decision: block,要求补单测 |
已有带标签测试(含未提交的新测试文件);stop_hook_active 为 true 时放行,避免死循环 |
-
选
Stop而非每次改代码前检查,是为了不在写第一个测试文件之前就反复拦截。 -
hook 脚本自身出错(读不到输入、git 不可用等)一律放行,不影响会话。
-
业务源码、测试文件的判定与门禁共用
lib.mjs,规则只有一份。hook 只管 Claude Code 会话;开发者手写代码绕过时,由 MR 门禁兜底(缺需求发软提醒,缺测试在「测试场景覆盖」判 ✗)。
5. MR 门禁执行流程
5.1 触发条件
MR 且目标分支 ∈ {dev, uat, master},源分支为 feat/*。hotfix 及其他分支一律不生成评论。
5.2 执行时机:应对频繁合并
门禁结论只由「feat 分支代码 + 需求文档」决定,与目标分支、合并次数无关。同一个 feat 分支通常会依次合 DEV、UAT、master,联调期还会反复合 DEV;每个 MR 都跑一次,就是同一份内容被重复分析(参考 mr_review 实测,一次约 $2.5、5 分钟)。参考「幂等批次号 + fail-fast 漏斗」的做法,执行时机由四个机制控制:
| 机制 | 做法 |
|---|---|
| 前置漏斗(零 LLM 成本) | 依次检查:非 feat/* 或无 TAPD ID → 跳过;diff 只涉及文档 / 锁文件 / CI 配置 → 跳过;需求文档缺失 → 脚本直接输出软提醒模板,不调 LLM |
| 内容指纹去重 | 指纹 = TAPD ID + 源分支 HEAD SHA + 需求文档 hash。已跑过的指纹不再调 LLM,评论只写「复用 !xxxx 的结果」并附链接;需求文档一改,hash 变化自动重跑 |
| 手动重跑入口 | MR 页手动 job(或评论命令),开发者需要时自己触发,不依赖自动时机(随 CI 接入一起配置,见 §6.4) |
| 取消过期任务 | 同一 MR 有新提交时,job 设 interruptible,取消仍在跑的旧任务(随 CI 接入一起配置,见 §6.4) |
指纹存放 :不引入额外存储。评论末尾写隐藏标记 <!-- verify-spec:fp=... -->,job 用 GitLab API 查同源分支其他 MR 的评论有无该标记(GITLAB_MR_REVIEW_TOKEN 已具备 api 权限)。当前 MR 自己的评论不算;缺少 CI 上下文、接口报错或超时(默认 10 秒)时不复用,照常跑 AI 比对。
效果:EAD 不变时合 DEV、UAT、master 三次只调用一次 LLM,另两次只发复用链接;只有代码或需求文档真的变了才重跑。
webhook 侧配合:只在 MR open 和「有新提交的 update」时触发,改标题、加标签、approve 不触发(由触发配置方实现)。
6. 组件详解
6.1 vitest 地基(G1)
| 配置 | 取值 | 为什么 |
|---|---|---|
| vitest | 0.34.6 |
与 Volta 锁定的 Node 16.20.2 兼容(vitest 0.34.x 基于 vite4,支持 node16) |
| 覆盖率 | @vitest/coverage-v8 0.34.6 |
与 vitest 同版本,v8 原生覆盖率 |
| 浏览器环境 | happy-dom ^21.0.4 |
Vue 2.7 import 链会拉入 window.localStorage / navigator.userAgent;用文件级 // @vitest-environment happy-dom 按需开启 |
| 默认 environment | node |
纯逻辑/数据契约测试不需要 DOM,默认省开销 |
| include | src/**/*.{test,spec}.{ts,js} |
测试就近放在业务代码旁 |
| alias | @→src、global→submodule |
对齐业务代码的模块解析 |
npm scripts :test(vitest run)、test:watch(vitest)、test:cov(vitest run --coverage)。
6.2 数据收集脚本 verify.mjs(准备层)
确定性收集数据,并决定要不要调 AI。恒 exit 0,不做任何阻断:
-
取源分支:优先
MR_SOURCE_BRANCH(CI 下 checkout 是 detached HEAD,git branch取不到),本地回退到当前分支;目标分支取MR_TARGET_BRANCH,本地为空时不按目标过滤。 -
从分支名提取 TAPD ID(
\b(\d{7,})\b),读取需求文档.superpowers/sdd/{ID}.md(兼容历史位置.claude/specs/{ID}.md)。 -
走前置漏斗、计算内容指纹、查复用(§5.2),得出
action。 -
仅
review时才收集重数据:三点 diff(排除锁文件/快照)、跑vitest run -t "TAPD-{ID}" --reporter=json(stdout/stderr 全吞、只读 outputFile、运行前删除残留报告)。输出契约:
| action | 含义 | 附带字段 |
|---|---|---|
skip |
不在门禁范围,不发评论 | reason |
soft |
缺需求文档 | comment(脚本生成的软提醒,不调 LLM) |
reuse |
同指纹已在其他 MR 验证过 | comment(复用链接 + 指纹标记,不调 LLM) |
review |
交 skill 做 AI 比对 | spec、testReport、gitDiff、changedFiles、relevanceExcluded、fingerprint |
relevanceExcluded 列出不参与无关变更判断的文件:需求文档本身、带本需求标签的测试、子模块指针(从 .gitmodules 读取)。
前置漏斗里「只涉及文档 / 锁文件 / CI 配置」的判定范围:*.md、锁文件、*.snap、.gitlab-ci.yml,以及 docs/、.claude/、.superpowers/、.husky/、.vscode/ 目录。
三点 diff 是正确性关键 :用 origin/master...HEAD(merge-base)而非两点 origin/master。两点 diff 在 master 领先本分支时会把 master 独有的提交误算成本次改动(实测曾出现 291 文件 vs 实际 13 文件的失真),无关变更判断会彻底失效。
6.3 verify-spec skill(审查层)
设计原则
| 原则 | 含义 |
|---|---|
| 纯产出 | CI 无人值守,不询问、不等输入,只产出一段 MR 评论正文 |
| 判定与执行分离 | 数据由 verify.mjs 确定性收集,skill 只做语义比对 |
| 只对前端下结论 | 需求提到但前端 diff 没有的点,只列为「前端未涉及」中性信息,不判 ⚠/✗(前端 diff 看不到后端实现) |
| 结论可核实 | 每条判定都引用具体位置(文件:行号)与对应需求点,禁止「部分改动与需求无关」这类笼统表述 |
| 不阻断 | 给出判定但不拦合并;找不到需求文档只发软提醒 |
判断标准
| 维度 | 比对 | ✓ | ⚠ | ✗ |
|---|---|---|---|---|
| 测试场景覆盖 | 需求要求的场景 ↔ 测试报告的 describe + it |
关键场景都有测试且 Pass | 部分场景缺测试,或测试报告不可用 | 核心功能点完全没有测试 |
| 需求对齐 | 需求描述 ↔ 代码 diff | 实现与需求一致 | 基本符合但有细节差异 | 与需求明显矛盾(逻辑写反、做成了别的功能) |
| 无关变更 | 每个 diff hunk ↔ 需求点 | 所有改动都能追溯到需求 | 存在无法追溯到任何需求点的改动 | --- |
无关变更按 hunk 判,不按文件判 :无关改动常藏在相关文件里(如在 data.ts 改需求维度时顺手重构了另一个函数),只看文件名永远查不出。每段改动归入三类:
-
直接实现:能对上需求里的具体某一条 → 相关
-
必要支撑:实现需求离不开的类型定义、i18n 文案、被调用的公共函数、必要配置 → 相关
-
无法追溯:对不上任何需求点,也不是哪条需求的前置(顺手重构、动了别的功能、残留调试代码)→ ⚠ 疑似无关
无关变更只给 ⚠ 不给 ✗:重构、顺手修复天然是灰区,可能是有意为之,AI 分辨不了,只提示「请确认是否有意为之」。
结论:三项全 ✓ 为通过;有 ⚠ 无 ✗ 为待关注;有任何 ✗ 为严重偏离。「前端未涉及」不影响结论。
输出结构
找到需求文档时,评论按固定模板输出:
-
测试场景覆盖:判定 + 一句话理由 + 缺失场景
-
需求对齐:判定 + 一句话理由 + 偏离点 + 前端未涉及的需求点
-
无关变更:判定 + 每条疑似无关改动的
文件:行号范围、改了什么、为什么对不上需求 -
验证结论:通过 / 待关注 / 严重偏离,注明「仅作提示,不阻断合并」
评论末尾附隐藏指纹标记(见 §5.2)。skill 先看
action:skip不产出正文;soft/reuse原样输出脚本给的comment,不做比对;只有review才按上面的模板产出报告。