把仓库交给一个陌生人,他能不能只凭仓库完成启动和一次验收?
答「否」,他做不到的那一步就是缺失的那一份前置——多半是账号来源、服务地址,或无依据的那句原文。要补的是这一句,不是整篇重写。
README 只回答一件事:怎样把它跑起来。不写验收、不写目录树、不写字段表——这三样各有归属,抄过来就多出三处会过期的地方。启动方式变了,只改这一份。
命令清单与 Makefile 里的目标一一对应;端口与变量名与 .env.example 一致。把字数控制在 80 行以内。
# 笔记服务 一句话说明这个服务做什么、给谁用。 ## 环境要求 - Go 1.22 或更高(以 go.mod 为准) - Docker 与 Docker Compose ## 启动 1. cp .env.example .env 2. 生成会话密钥并填进 .env:openssl rand -base64 32 3. docker compose up --build -d 端口默认 8080,改 .env 里的 WEB_PORT。 打开 http://localhost:8080/healthz 应返回 200。 ## 常用命令 - make check 本地与持续集成跑同一条检查 - make fmt 排版,会改文件 - make layout 顶层目录白名单检查 - make migrations 迁移文件命名与成对检查 - make migrate-roundtrip 迁移上、全部下、再上(只对测试库跑) ## 文档在哪 - 决议:docs/decisions/ - 验收:docs/notes-check.md - 接口:docs/api-notes.md 目录结构不在这里重复,见 docs/decisions/0035-layout.md。
可以省的两样:「环境要求」里的版本下限(go.mod 里已写着),以及架构图与性能指标——后两样没有真实数据时写出来只会过期。
每一条验收都写成三部分:前置、操作、可判断的期望。期望写成状态码、某句原文、某个日志字段——「体验良好」「基本可用」不能作为通过条件,因为它们无法被两个人独立判成同一个结果。
读者是不认识这个项目的人,他只使用仓库和本篇,不依赖口头补充。状态码与日志字段与接口、日志两页保持一致。
# 笔记读取验收 读者:核对接口和页面的人。只使用仓库和本篇,不依赖口头补充。 前置: - 已按 README 启动。 - 所属范围 A、B 各有一个账号,各有一条标题不同的笔记。 - 无依据的页面原句以本篇「没有依据」一节为准。 ## 本范围可以读到 1. 用所属范围 A 的账号登录。 2. 打开该范围的一条笔记。 3. 期望:状态码 200,响应或页面中有这条标题。 ## 其他范围读不到 1. 保持所属范围 A 的登录。 2. 请求一条已知属于所属范围 B 的 id。 3. 期望:状态码 404,正文与「id 不存在」相同,不出现所属范围 B 的标题。 4. 日志中有 cross-scope access denied,并带有两个不同的 scope_id。 ## 没有依据 1. 用一段材料中不存在的问题做检索。 2. 期望:结果为无依据的原句,不出现材料里没有的讲解, 本轮不调用对话模型。 ## 留档 命令、退出码、端口、字符集记在本节下方,与本次验收的日期一起提交。
界面验收怎么写期望:把期望写成页面上应出现的原句,比写成截图更可靠——截图会随样式改变而过期,原句不会。需要核对布局时,把截图放在改动差异里,不进 docs/。
## 验收标准 - 登录体验良好 - 数据安全 - 性能满足要求
认它的办法:把仓库交给一个陌生人,他能不能只凭仓库完成启动和一次验收。三条都判不了——没有状态码、没有页面上该出现的原句,两个评审会给出相反结论。助手写验收很自然,因为「体验良好」这种写法在方案讨论里是合格的;验收文档要的恰恰相反:一段能逐字比对的原句。
「文档要跟着代码更新」不构成机制,因为它没说清改了什么必须更新哪份、谁检查。下面这张表的每一行都能用一条命令核对。
| 代码改动触及 | 必须同一次更新的文档 | 谁检查 |
|---|---|---|
.env.example | README.md 的启动步骤与环境要求 | 脚本(自动) |
migrations/ | 验收文档里的相关前置与期望;docs/decisions/ 里数据决议的字段清单 | 脚本 + 评审人 |
| 检查阈值、工作流、规则集 | docs/decisions/ 里对应决议的「怎么查」一节 | 脚本 + CODEOWNERS 指定的必审人 |
scripts/check-layout.sh 的白名单 | docs/decisions/0035-layout.md | 脚本 |
internal/http/ 或路由注册 | docs/api-<资源>.md 的四句话 | 脚本 + 契约测试 |
| 发布步骤、回滚点、版本号规则 | docs/release.md 与本次发布说明 | 发布前由发布人按清单核对,见发布 |
| 界面上的「无依据」原句 | docs/notes-check.md 与界面核对用例 | 评审人 |
Makefile 的命令入口 | README.md 的常用命令清单;AGENTS.md 里提到的命令 | 评审人 |
前五行自动检查。它在合并请求的检出目录里运行,BASE 默认 main。规则写成「触及左边就必须出现右边」,所以要放宽时改的是这个文件,评审会看到。
#!/bin/sh
# 文档同步检查:改动触及左边的路径时,右边必须出现在同一次改动里。
changed=$(git diff --name-only "origin/${BASE:-main}...HEAD")
status=0
check() {
if echo "$changed" | grep -qE "$1"; then
if ! echo "$changed" | grep -qE "$2"; then
echo "文档未同步:改动命中 /$1/,但 /$2/ 未更新"
status=1
fi
fi
}
check '^\.env\.example$' '^README\.md$'
check '^migrations/' '^(docs/|internal/.*_test\.go)'
check '^(pyproject\.toml|\.golangci\.yml|\.github/workflows/)' '^docs/decisions/'
check '^scripts/check-layout\.sh$' '^docs/decisions/0034-layout\.md$'
check '^internal/http/' '^docs/'
exit $status
本地跑时基线取 main,所以先 git fetch origin main 再跑。BASE 可以在持续集成里改成合并目标分支。make check 的聚合入口只在质量门禁里定义一次,这一页只加子目标。
.PHONY: docsync docs-check docsync: sh scripts/check-doc-sync.sh # 挂进 check:check: fmt-check lint type docs-check secret complexity test docs-check: docsync ruff check --output-format=concise .
脚本管不到的地方:它只在改动差异的文件名上做判断,它不知道文档内容有没有真改。给 README.md 加一个空行也能让它变绿。所以第六到第八行仍要评审来看——脚本的作用是把「忘了更新」这类问题从人身上挪走,不是替代人。
同一个东西在文档、代码、界面上叫三个名字,会让人以为它们是三件事。下面五个词出现过歧义,各留一个说法。代码与 SQL 里的英文名保持原样,在文档里第一次出现时写「所属范围(scope_id)」。
| 只用这个说法 | 不要写成 | 英文名(代码里保持) |
|---|---|---|
| 所属范围 | 租户、班级、组织、部门 | scope、scope_id |
| 笔记 | 文档、条目、卡片 | note |
| 无依据 | 未命中、拒答、没有答案 | (界面原句单独维护一份) |
| 决议 | ADR、决定、规约文档 | docs/decisions/ |
| 检查 | 门禁、校验、质量关卡 | make check |
它只检查仓库内的相对链接。不检查外网链接——外网会因为网络和限流误报,每次都红会让人开始忽略这条检查。
lychee --offline --no-progress README.md 'docs/**/*.md'
外网链接怎么处理:外网链接过期要人处理,做法是把它列进「每季度检查一次」的清单,由一个人在一个时间点跑一次带网络的检查。工具名与参数以官方文档为准。
docs/ 里搜得到,右列搜不到。违反后果:读者以为「租户」和「所属范围」是两个不同的概念。sh scripts/check-doc-sync.sh 退出码 0。违反后果:文档落后一个版本,且没有任何东西会在下次提醒。README.md 中不含以 ├── 或 | 字段 开头的行。违反后果:目录一变,README 就成了第三份会过期的描述。第四条不触发的情况:纯文档改动(只改 docs/ 或只改 README.md)时它因为「没有代码改动」而不触发任何检查,这是正常的。此时靠评审来看术语与期望是否仍然可判断。