规约的覆盖面决定检查的覆盖面:没人评审,写的人就是唯一验收标准;没并回基线,读到的是过期版本。
评审规约的人,能不能在不看代码的情况下答出「这份规约写全了没有」?
答「不能」,通常是评审在看实现细节,而不是在看覆盖面和期望值。按下面的清单逐条回答「是 / 否」,比自由讨论更快。
把规约和代码放进同一次合并请求:进仓库、开合并请求、至少另一人看过才合。不能既当提出人又当评审人——自己写自己批的规约,等于没有第二个人读过。
| 评审代码 | 评审规约 | |
|---|---|---|
| 要回答的问题 | 这是不是一段正确的实现? | 这是不是一份完整、无歧义的行为描述? |
| 看什么 | 改动差异、分支覆盖、异常处理 | 场景是否覆盖正常与失败两类、期望值是否具体 |
| 通过标准 | 行为正确,检查通过 | 另一个人能只照它写出测试 |
| 挡回时说什么 | 「这里会崩」「这里漏了判断」 | 「这条 Then 不是具体值」「这类失败路径没写」 |
| 顺序 | 先读差异,再跑检查 | 先读规约,再抽读与规约对应的那几处代码 |
五类追问对应五类容易漏掉的东西。每一条「动作」列都直接给出要做的那一步,评审意见才不会停在「再改改」。
| 追问 | 答「否」说明什么 | 动作 |
|---|---|---|
| 这次明确不做什么?不做的事情写下来了吗? | 边界没画,实现的人会顺手多做一块 | 补 proposal.md 的「这次不做」,至少 3 条 |
| 正常、越权、非法输入、依赖失败,哪一类还空着? | 有一整类失败路径没人想过 | 按可执行规约里的四类失败路径各补 1 个场景 |
| 每条 Then 是不是具体值? | 期望值不可判断,验收时会吵架 | 状态码写数字,字段写名字,比例写阈值 |
| 这条要求对应哪条检查?现在跑得起来吗? | 规约只是一份文档 | 接进 make check,跑不出红就先别合 |
| 这个方案要引入什么?有没有更小的做法? | 成本没被算过 | 写进 design.md 的「舍弃的方案」,并说明代价 |
上面那些追问已经写成能回答「是 / 否」的问句,评审时不用凭印象。规则只有两条:遇到一个「否」就挡回,并在合并请求里写明是哪一条;不适用的条目要写「不适用 + 理由」,不能留空——留空等于没人看过。
评审人把这份文件贴在合并请求里逐条回答,答完再点批准。
# 规约评审清单 - 适用:openspec/changes/<change-id>/ 下的规约改动 - 用法:每条回答「是」或「否」;一个「否」即挡回,并写明是哪一条 - 不适用:写「不适用 + 理由」,不能留空 ## 提案 1. proposal.md 里有没有写「这次不做」? 2. 「这次做」的每一条能不能指到接口、页面或数据表? 3. 有没有哪一条既不在「做」也不在「不做」里? ## 场景 4. 每个 Scenario 都有 Given、When、Then 三段? 5. 每条 Then 是不是一个具体值(状态码、字段名、数量、阈值)? 6. 越权、非法输入、依赖失败、状态不合法,四类里有没有哪一类完全没写? 7. 有没有哪条要求只有一句陈述、没有对应 Scenario? 8. 「友好」「准确」「清晰」这类没有阈值的词,出现过吗? 9. 文件顶部有没有写覆盖接口与检查入口? ## 可验收 10. 每个 Scenario 能不能对应到一条测试或一条查询? 11. 现在跑得动吗?跑不过的那几条,是本次要实现,还是已有回归也红了? 12. 一票否决项(越权泄漏、编造出处、未登录返回片段)有没有写成 0 容忍? ## 三方一致 13. 规约、tasks.md、代码改动是否在同一次合并请求里? 14. design.md 里被舍弃的方案,理由下一个人看得懂吗? 15. 有没有和 openspec/specs/ 基线的现有条款冲突?冲突怎么处理的写下来了吗?
## 评审结论 1. 提案写了「这次不做」? 是 4. 每个 Scenario 都有三段? 是 6. 四类失败路径都写了? 是 ... 15. 与基线冲突怎么处理的? 是 评审意见:LGTM,规约写得很完整。
认它的办法:挑一条答「是」的,让它指到规约里的具体一行。15 条全「是」的清单通常一条都指不到——这份规约其实只有正常路径,而助手照着规约写结论时,把「规约里写了什么」当成了「规约写全了什么」,这两件事在只有正常路径的规约上完全一样。所以清单要求「不适用」也要写理由:留空就说明没人看过,全「是」和没人读过分辨不出来。
规约描述行为,代码实现行为。在两者不一致时说「先这样,回头补」,回头不会来,下一个读这份规约的人拿到的是错的验收标准。
还有一类不一致更隐蔽:行为没变,但规约里的说法过期了(接口改名、队列换了实现)。每月清一次这种条目,一次清一批,不要攒到季度末。
负责的这个变更验收通过之后,把增量并回基线,再把变更目录归档。这样仓库里始终有一份「当前系统是什么行为」的规约,而不是一堆历史提案。两个目录的差别只有一条:changes/ 里的东西可能被否掉,specs/ 里的东西一定是当前真的行为。
| 位置 | 放什么 | 谁写 | 什么时候变 | 存活多久 |
|---|---|---|---|---|
openspec/changes/<change-id>/ | 进行中的提案:四件套 + verify.md | 提出人 | 立项时创建,验收后移走 | 通常 ≤ 2 周 |
openspec/specs/ | 当前行为基线,按接口或模块分文件 | 提出人验收后并入 | 每次变更验收通过时更新 | 长期,随行为变化更新 |
openspec/changes/archive/ | 已完成的提案,只作追溯 | 提出人 | 验收后从 openspec/changes/ 移入 | 长期留存,不再修改 |
被否掉的提案也要归档:在 proposal.md 末尾加一行「结论:不采纳,原因……」,再移进 openspec/changes/archive/。下一个翻仓库的人看到为什么不这么做,才不会把同一个方案再提一次。
openspec/
config.yaml # openspec init 生成:给 AI 看的上下文与规则
changes/ # 进行中,可能被否掉
async-grading/ # change-id,不带日期
proposal.md
design.md
tasks.md
verify.md
specs/
grading/
spec.md
archive/ # 已完成,只作追溯
2026-10-03-class-isolation/ # 日期由 OpenSpec 归档时加
proposal.md
design.md
tasks.md
verify.md
specs/
class-isolation/
spec.md
specs/ # 当前基线,等于「系统现在是这样」
auth/
spec.md
class-isolation/
spec.md
grading/
spec.md # 异步批改验收后并进来
边界:不要在 specs/ 里留历史版本。历史在版本控制里查,不在文件里堆——同一份文件里叠着三个版本的条款,读的人分不清哪条现在有效。要查某个条款什么时候变的,用下面的版本记录。
版本控制记得住「文件变了」,记不住「哪一条变了、为什么」。所以在每个 specs/ 文件顶部放一张变更记录表,四列缺一不可:日期、谁、改了哪一条、为什么。
表格只记「改变行为」的改动,改错别字不记。
# 班级资料隔离
- 状态:已定,2026-10-03
- 覆盖接口:POST /api/kb/search、GET /api/kb/doc/{id}
- 检查入口:make check(pytest tests/spec/test_class_isolation.py)
## 变更记录
| 日期 | 谁 | 改了哪一条 | 为什么 |
|---|---|---|---|
| 2026-10-03 | 张 | 新增 Requirement「检索按班级隔离」及 3 个 Scenario | issue #42,跨班可检索 |
| 2026-10-17 | 李 | Scenario「未登录」期望由 403 改为 401 | 与 auth.md 统一 |
| 2026-10-24 | 张 | 删除 Scenario「管理员跨班检索」 | 与提案的「这次不做」重复,管理员行为写在 auth.md |
每条记录都要能对上一次提交。查法两条:git log --oneline -- openspec/specs/class-isolation/spec.md 看提交,git blame openspec/specs/class-isolation/spec.md 看某一行是哪个提交加的。表格和提交对不上时,以提交为准——表格是给人读的索引,提交才是记录。