Files
knowledge-governance/docs/agents/traps.md
T
姚章浩 4f8ee0a8c5 feat(version): 变更记录改固定四类,破坏性变化带发布文档链接
按项目方给的规则重做 CHANGELOG 与检查器(本轮不推、不打 tag、不部署):

- 条目形状:四个自定义必填节(改了什么/兼容性与迁移影响/怎么验收/已知问题)
  → 固定四类 Added/Changed/Fixed/Security;分类可省略,**不许空栏目**;每个版本块
  至少一个非空分类;`[Unreleased]` 段不得写日期。
- 破坏性变化:固定写在 Changed 里、以 `**破坏性**` 开头,**必须链接发布文档**;
  仓内 `docs/….md` 链接**对账**(不存在 ⇒ 红)。写在别的分类里也判红。
- 结构判据补强:非法日期、重复版本、顺序必须严格递减、前导零;围栏代码块里的
  `##`/`###` 不算标题;**块正文以任意二级标题为界**(「## 能力基线(未发布)」这类
  不带方括号的标题原先不是边界,会把后文算成上一节的内容 ⇒ 空栏目被兜成"有内容",
  夹具真的抓到了这个假绿)。
- tag 一致性:远端专属清单 `.ci-remote-tags` → `ls-remote` → 本地 refs 三级来源;
  **没核对远端就不许说校验成功**(CI/预检用 `--require-remote`,拿不到清单即红),
  非正式 tag(如本地 `x`)不当作发布版本、不判红,没有 tag 不判红。
- `0.1.0` 定为**未发布的能力基线**(远端无 tag、无 Release):改成不带方括号的
  「## 能力基线(未发布)」,不补造 tag/发布日期/发布事实。
- 过程记录迁出:审查轮次与命令输出 → `docs/archive/CI_接入留档.md`(原文保留);
  未合并事项 → `docs/BACKLOG.md`;补 `docs/RELEASE_RUNBOOK.md`(破坏性变化的链接目标,
  同时修掉 CHANGELOG 里指向不存在文档的悬空引用)。
- 发版预检:无版本基线时把「首个正式版本号由人指定」列成阻塞项(不再要求先有基线才能跑);
  打印破坏性变化条数;远端 tag 查不到时明说未核对并阻塞;不再硬读已删除的『已知问题』节。
- traps trap-5 口径修正:不是"不用 tag",而是"构建时不要用 git describe 取版本号"。

验证:check_changelog --self-test 全过;反仓夹具 80/0(含五条真改文件的结构负例、
六条只在临时仓库里造 tag 的场景);make verify 15/15 success。
2026-10-13 14:59:35 +08:00

348 lines
20 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.
# 已知陷阱(全文)
> 这份文件是 `AGENTS.md` 第 4 节那张索引的**全文**。拆成两份是有原因的:
> `AGENTS.md` 作为"工作区指令"会被自动加载,而**指令有字节预算** —— 超出的部分会被
> **静默截断**,没有任何报错。索引留在指令文件里(一行一条,一眼扫到),全文放这里。
>
> 纪律:**只追加、不重排、不复用旧号** —— 编号会被代码注释、测试、文档引用(形如"见 trap-3")。
> 新增一条要改两处:这里加全文 + `AGENTS.md` 的索引表加一行。
## trap-1:`.gitignore` 对**已入库**的文件无效
**症状**:规则明明写了 `__pycache__/`,`git ls-files` 里却仍有 8 个
`scripts/__pycache__/*.pyc`。
**根因**:`.gitignore` 只影响"未跟踪"的文件。文件一旦进了索引,规则对它就完全不起作用,
而且**不会有任何提示**。
**判据**:`git ls-files | grep -E '__pycache__/|\.pyc$'` 输出非空即是这条。
**修法**:规则 + `git rm -r --cached <path>`(保留磁盘文件)。
规则齐全不等于没被跟踪 —— 断言要查**两件事**:规则在、且 `git ls-files` 里没有。
**反例**:只加 `.gitignore` 规则就以为修好了。下次 `git add -A` 时它照样进来。
## trap-2:`git rm --cached` 之后跑 `git add -A`,文件会被搬回索引
**症状**:执行了 `git rm --cached node_modules`,`git status` 也显示删除了,
提交后 `git ls-files` 里那个文件**仍在**,净效果为零。
**根因**:`--cached` 只从索引移除、**磁盘文件留着**。如果此刻忽略规则**还没生效**
(本仓当时只写了 `/web/node_modules/`,管不到根级 `node_modules`),
紧接着的 `git add -A` 会把这个"存在且未被忽略"的文件重新加回索引。
**判据**:`git rm --cached X` 后、提交前,先跑 `git check-ignore -v X` ——
必须命中一条规则,否则 `add -A` 会把它捡回来。
**修法**:**先让忽略规则生效,再撤出索引**,并且此后只 `git add <具体文件>`,
不要用 `-A`。
**反例**:把两步写成一条 `git rm --cached X && git add -A && git commit`。
## trap-3:用子串判断代替整行判断
**症状**:脚本判断"规则已存在"时写 `if "/node_modules/" not in 全文`,
而文件里已有 `/web/node_modules/` —— **它包含这个子串**,于是误判为"已存在",
跳过了追加。结果规则根本没加上,而脚本打印"规则已存在"。
**根因**:`.gitignore` 的 `/web/node_modules/` 与 `/node_modules/` 语义完全不同
(前者只管 `web/` 下),但作为字符串是**包含关系**。
**判据**:任何"这条规则在不在"的判断,都要对**行集合**做精确比较
(`{l.strip() for l in lines}`),不要对全文做 `in`。
**反例**:`if "xxx" not in text` 这类判断在多条相似规则并存时必然出错。
## trap-4:Gitea 合并接口成功时返回**空 body**
**症状**:脚本调用合并接口,HTTP 200,但解析 JSON 抛
`json.decoder.JSONDecodeError: Expecting value: line 1 column 1`,
看起来像"合并失败",于是重试 —— 而 PR 其实已经合并了。
**根因**:Gitea 的 `POST /pulls/{n}/merge` 成功时 body 为空(不是 `{"merged":true}`)。
把"HTTP 200 + 空 body"当成失败是客户端的问题,不是服务端的问题。
**判据**:合并后 **不要**信 POST 的返回,改 `GET /pulls/{n}`,
要求 `state=closed` + `merged=true` + `merge_commit_sha` 非空。
**反例**:在合并接口上做重试。**永不重试** —— 会得到"第二次合并失败"的假故障。
## trap-5:浅克隆里 **`git describe --tags` 会静默退化**(≠ 不能用 tag)
**症状**:想让版本号来自 `git describe --tags`,在 CI 上失败或给出误导结果。
**根因**:工作流里是 `git fetch -q --depth 50 origin "$BR"`,浅克隆不带 tag。
`git describe` 要么报 `No names found`,要么退回到一个和预期无关的短 sha。
⚠️ 这是"**不要在构建时靠 `git describe` 取版本号**",**不是**"本仓不用 tag" —— 正式版本
**就是**用注释 tag `vX.Y.Z` 标记的(由人批准后创建)。
**判据**:任何依赖 tag 历史的**自动**步骤,必须在 CI 里显式
`git fetch --tags` / `--unshallow`;否则就是这条。
**修法(本仓采用)**:
- **版本号的唯一来源**是仓库里的 `CHANGELOG.md`(`python3 scripts/version.py`),文件一定在,不依赖 tag;
构建身份(含开发标识)用 `scripts/version.py --identity`,`make version` 打印它。
- **tag 一致性由人可核验的方式检查**:检出步骤专门落一份远端 tag 清单
(`.ci-remote-tags`)并 `git fetch --tags` 取对象,`scripts/check_changelog.py --tags` 再核
「tag 版本 == 它指到那个提交里的版本记录」、且必须是**注释 tag**;
**没有 tag 不判红**,但**没核对远端就不许说"校验成功"**(CI 用 `--require-remote` 强制)。
**反例**:`VERSION=$(git describe --tags --always)` —— 本地看着正常,CI 上静默退化。
反例二:检查器在拿不到远端 tag 清单时照打 `✅ 校验通过` —— 那是"假绿",现在会明确写「未核对远端」。
## trap-6:act_runner **不会**自动检出代码
**症状**:工作流第一步就报找不到文件 / 空工作区。
**根因**:本仓的 act_runner 不会替作业做 `git clone`,工作区起始为空。
**判据**:工作流里若没有 `git init` + `git remote add` + `git fetch` + `git checkout`,
就是这条。
**修法**:照 `.gitea/workflows/build.yml` 的「检出代码」步写(带 `x-access-token` 的
remote,令牌来自 `secrets.GITHUB_TOKEN`)。
**反例**:以为 runner 会像 GitHub Actions 那样自动 checkout,直接写业务步骤。
## trap-7:`grep -q` 在 `pipefail` 下会被 SIGPIPE 谎报失败
**症状**:`echo "$OUT" | grep -q 'PASS'` 明明匹配上了,整条命令却返回非 0
(141),在 `set -o pipefail` 的作业里表现为"检查失败"。
**根因**:`grep -q` 一命中就退出并关闭管道,上游 `echo` 随即收到 SIGPIPE,
整条管道返回 141。
**判据**:`set -o pipefail` + `... | grep -q ...` 同时出现即是这条。
**修法**:用 `case "$OUT" in *PASS*) ... ;; esac`,或让 `grep` 直接读文件;
非用管道不可时用 `set +e; set +o pipefail` + `${PIPESTATUS[0]}`。
**反例**:`if echo "$x" | grep -q y; then` —— 时红时绿,且方向完全错。
## trap-8:`gofmt -l` 列出文件时**退出码仍是 0**(只看退出码=假绿)
**症状**:门禁里写着 `gofmt -l .`(或 `step gofmt -l . || fail=1`),格式乱掉的文件明明被列出来了,
这一步却**通过**。整条门禁全绿,而仓库里躺着一堆没格式化的文件。
**根因**:`gofmt -l` 的正常行为就是"列出不合规的文件并返回 0" ——
它的退出码表达的是"我跑完了",不是"都合规"。用退出码当判据,等于没判。
**判据**:任何"输出即问题"的工具(`gofmt -l`、`prettier --list-different`、`git status --porcelain`)
都必须**判输出是否为空**,不能判退出码。
**修法**:`[ -z "$(gofmt -l <文件...>)" ]`,或者像本仓 `scripts/check_format.py` 那样解析输出;
并且给它一条**故意弄坏的夹具**证明它真的会红(否则"永远返回 0 的校验器"会一路绿灯)。
**反例**:`step gofmt -l . || fail=1` —— 生成器里就是这么写的,一直没人发现。
## trap-9:`tar -C dir .` 产生 `.` 根成员 ⇒ 预览 404
**症状**:打包与上传都成功、通知也发了,点开预览链接却是 404 或报错。
**根因**:`tar -czf x.tgz -C dir .` 会把 `.`(以及 `./index.html`)写进成员名。
严格的解包器要么拒绝整包,要么解出一层多余的 `.`,于是 `index.html` 不在预期的位置上。
**判据**:`tar -tzf <包>` 的第一行若是 `.` 或 `./…`,即是这条。
**修法**:先 `cd` 进产物目录,再用通配展开成员名(`( cd "$WEB" && { shopt -s dotglob; tar -czf "$TAR" *; } )`),
并在发之前断言包里真的有 `index.html`(`tar -tzf "$TAR" | grep -qx 'index.html'`)。
**反例**:只看"打包成功 / 上传 HTTP 200"就当预览发好了 —— 那两步都不会因为包结构不对而失败。
## trap-10:依赖清单被静默改写
**症状**:一次 CI 通过之后,`go.mod` / `go.sum` / `package-lock.json` 与提交里的不一样了;
本地跑一遍还多了/少了几个依赖,没人改过它们。
**根因**:两个"顺手"的默认行为 —— `GOFLAGS=-mod=mod`(构建时自动补全/更新 `go.mod`)、
以及缺锁文件时回落到 `npm install`(按 `package.json` 重算整棵依赖树并重写锁文件)。
两者都让"依赖清单"变成**运行时产物**,而它本该是人 review 过的契约。
**判据**:夹具 = 门禁开始前后对这几个文件取 sha256;任何一步改写了它们 ⇒ 红。
**修法**:`GOFLAGS=-mod=readonly`;缺锁**直接失败**(不接受无约束安装);
`npm ci`(按锁装、锁与 `package.json` 不一致就报错)。见门禁的 `deps-install` 与 `manifests-locked` 两项。
**反例**:`if [ -f package-lock.json ]; then npm ci; else npm install; fi` —— 看起来"更健壮",
实际是给漂移开了后门。
## trap-11:汇总把「未运行 / 被跳过」当「通过」
**症状**:某个必需检查压根没跑(失败即停的后果、或运行时被 `if:` 跳过),
汇总却报"全绿";事后看报告,那一项**根本不在报告里**,谁都没发现。
**根因**:汇总逻辑只遍历"跑过的项"。没跑的项不出现 ⇒ 集合变小 ⇒ 全绿。
另一种写法是 `skipped` 与 `success` 一起放行。
**判据**:给汇总喂一个"必需项缺失 / 必需项 skipped"的夹具,它**必须**非绿。
**修法**:清单里声明必需项集合,汇总按**清单**(不是按结果数组)逐项核对;
缺项记 `not_run` 并判红;`aggregate()` 只认 `success`。
不适用要**在清单里声明**(本仓的 `required_in`),不能在运行时悄悄 skip。
**反例**:`status = "failure" if any(x == "failure" for x in results) else "success"`。
## trap-12:事件 SHA ≠ 检出 SHA
**症状**:CI 里跑的是"分支当前 HEAD",而这次运行的事件指向的是**另一个提交**。
表现是"我刚推的那一步好像没跑",或者更糟 —— 拿新代码的结论去解释旧代码的产物。
**根因**:工作流写 `git fetch --depth 50 origin "$BR" && git checkout -B "$BR" FETCH_HEAD`,
取的是**当时**的分支 HEAD。只要在事件产生之后又有人推了一次(或重跑旧运行),两者就分叉。
**判据**:`git rev-parse HEAD` 必须等于 `${GITHUB_SHA}`(**完整** SHA,不是短 sha)。
不一致 ⇒ 红。
**修法**:检出后立刻核对;不一致时改按精确 SHA 取(`git fetch --depth 50 origin "$EVENT_SHA"`),
再取不到就失败。门禁的 `identity` 项会**再核对一次**(两道,故意的)。
**反例**:报告里只写"分支 main 的构建成功" —— 这个结论没有对应的提交。
## trap-13:密钥扫描规则过宽 ⇒ 永远的假红
**症状**:接入密钥扫描后立刻红,命中全是**正常代码**:`postgres://{u.quote(user)}:{u.quote(password)}@host/db`
(模板表达式)、`postgres://governance:***@postgres/governance`(脱敏占位)、
`X-CI-Token: ${BRIDGE_TOKEN}`。
**根因**:规则用 `[^@\s]{6,}` 这种"很宽"的字符类去抓口令段,于是把变量插值、脱敏星号、
模板表达式全部当成密钥。假红一旦出现,人就会把整个检查注释掉 —— 那比没有检查更糟。
**判据**:扫描器必须自带**双向夹具**:合成的真密钥要命中,**良性对照要零误报**,
而良性对照里要**钉住踩过的误报形状**(防回归)。
**修法**:只收"干净字符"的口令段(`[A-Za-z0-9+/=_\-]{6,}`),并对命中值做占位符判定
(`${...}`、`<...>`、`***`、`changeme`…)。见 `scripts/check_secrets.py` 的 `RULES` 与 `--self-test`。
**反例**:为了让门禁变绿去放宽/注释规则 —— 那是降低规则换绿灯,规范 §4.2 明确禁止。
---
### trap-14|"这个语言我没写检查" = 假报成功(通用模板的 Python 门禁空跑)
**症状**:通用模板套到一个 Python 项目上,`deps-install / format / static / build / tests` 五项**全绿**,
但一个 Python 检查都没跑 —— 函数体只打印一句话就 `return SUCCESS`。
**根因**:模板最初只为 Go+Node 写实现,Python 分支留了"占位返回成功"。占位返回成功不是"还没做",
是**假报成功**:它会让人以为这个项目被检查过。审查是靠"模拟一个 Python 项目,看那五项是不是真的执行了"
才发现的 —— 只看 YAML/看代码是看不出来的。
**判据**:每一门识别到的语言都必须**真的执行**;工具缺失、依赖没按锁装、输出解析不了 ⇒ **失败**。
**修法**:`uv sync --frozen` / `uv run ruff format --check` / `compileall` / `uv run pytest`;
`need()` 找不到工具就抛错(转成 FAILURE)。前端同理:测试**进程退出码非零**必须红
(报告写 pass 不算数),框架按 `test-scope.json` 声明,认不出来就失败。
### trap-15|预览放在"报告核对"之前 = 发出去就收不回来
**症状**:预览步骤只读 `overall_status` + `commit_sha` 就发布;完整报告核对在后面的通知步骤。
报告缺项时,**预览已经发出去了**,几分钟后才报错 —— 那时 URL 可能已经被人打开/转群。
**根因**:把"发布"当成"通知"的一环。预览是**不可撤回**的对外动作,不能排在质量结论之前。
**判据**:发布前必须调用**同一份**核对逻辑(`ci_report.verify()`),有任何问题 ⇒ **一个 publish 请求都不发**。
**修法**:`.gitea/scripts/ci_preview.py` 内部先核对再发布;`scripts/ci_preview_negative_test.py`
用假 bridge **数请求**证明坏报告 0 次、好报告 1 次。
### trap-16|用"观察到的 context"代替"要求哪些检查"
**症状**:合并前没指定必需 context 时,脚本拿"体检时在该仓看到过哪些状态"当必需列表。
**根因**:`combined state` 只报"报过状态的都成功",一个**无关**的检查成功就能让它变成 `success`。
于是"没跑过必需检查"的 PR 会被合进去。
**判据**:要求哪些检查只能来自**明确策略**(`--require-context` 或 `.gitea/required-contexts.txt`);
策略缺失或为空 ⇒ 拒绝合并(空不等于"没有要求")。
**修法**:见 `scripts/adopt-existing.py` 的 `resolve_required_contexts()` 与 `--self-test-chain`
(假 Gitea + 真实 `merge_pr()`,断言 merge 请求发没发)。同时核对 base 分支/PR 状态/head 仓库。
### trap-17|remote 协议被"顺手"拼成 http
**症状**:注释说"保留服务端给的协议",代码却是 `git remote add origin "http://${HOSTPORT}/..."` ——
服务端给的是 https 时会**静默降级**成 http(凭据与产物走明文)。
**根因**:把 `${SRV#*://}` 取出来的 host:port 再拼一个固定的 scheme。取 host 的动作本身就把协议丢了。
**判据**:remote 直接用 `GITHUB_SERVER_URL` 原样拼,并**断言**最终 scheme 与服务端一致;
未知 scheme 直接拒绝;URL 里不许带凭据。
**修法**:`.gitea/scripts/ci_checkout.sh`(`--print-remote` 可本地验证协议保留);
YAML 里只调用它,不再内联这套拼接。
### trap-18|范围校验回退成"最近 N 条"或"接受截断的列表"
**症状**:范围算不出来时退化成 `git log -n 5`(和历史无关),或直接采信事件负载里那串 commits。
**根因**:事件负载里的 commits **可能是截断的**(实测:声明 6 条、列表只给 1 条),
浅克隆里 `before` 可能根本不存在 —— 两种都会让"本次改动"少核几条,而且全绿。
**判据**:以**准确 Git 范围**为准(push 用 `before..after`,新分支用 `after --not --remotes`);
事件声明条数与 Git 范围不一致 / 空范围 / 浅克隆取不到 before / 列表提交不在检出里 ⇒ 全部**拒绝**;
手工运行比不出范围时只核 HEAD,但必须**写明**。
**修法**:`.gitea/scripts/ci_gate.py` 的 `resolve_scope()`(`--self-test` 11 个分支)+
检出脚本顺带把事件 `before` 取下来。
---
### trap-19|第一步就调用仓内脚本 = 整条流水线起不来
**症状**:workflow 第一步写 `bash .gitea/scripts/ci_checkout.sh`。本地"已经有代码的目录"里跑得好好的,
真到 Runner 上返回 **127**(`bash: .gitea/scripts/ci_checkout.sh: 没有那个文件或目录`)——
因为这个 Runner **不自动检出**,工作区初始是空的(trap-6),此时仓内脚本还不存在。
**根因**:把"检出"写成了仓内脚本,而"检出"恰恰是**让仓内文件出现**的那一步 —— 循环依赖。
本地验证只在有代码的目录里做,就永远看不到这个形状。
**判据**:第一步必须是**最小 bootstrap**:只依赖 runner 给的环境变量(`GITHUB_SERVER_URL` /
`GITHUB_REPOSITORY` / `GITHUB_SHA` / `GITHUB_TOKEN`),自己 `git init` + `remote add` + 取**事件完整 SHA**
+ checkout + 断言 HEAD==事件 SHA。检出完成、脚本就位之后,**才**调用仓内脚本做后续校验与补取。
**修法**:`build.yml` 第一步内联 bootstrap → 然后 `bash .gitea/scripts/ci_checkout.sh --post-checkout`。
`scripts/ci_chain_fixture.py` 会把那段 `run:` **原样抽出来**在空目录里执行(YAML 一改坏就红)。
### trap-20|`fetch origin <SHA>` 取任意提交(服务端默认不允许)
**症状**:分支 HEAD 已经前进时,bootstrap 想"按精确 SHA 取",执行 `git fetch origin $GITHUB_SHA`
→ `服务器不允许请求未公开的对象`(upload-pack 默认不开放按任意 SHA 取)。
**根因**:把 `--depth` 取分支的写法套到按 SHA 取上。这个能力在默认配置下**不存在**。
**判据**:事件 SHA 必须能从**已取到的分支历史**里找到;不够深就 `--deepen` 再找;
仍找不到才是失败(提示提高深度或等重跑)。失败要明确,不许"取不到就换个分支糊过去"。
**修法**:见 bootstrap 里的 `git cat-file -e "${GITHUB_SHA}^{commit}"` + `fetch --deepen 200` 分支。
### trap-21|`after --not --remotes` 在检出后把范围排成空集
**症状**:新分支推送(`before` 为全 0)用 `after --not --remotes` 算范围。CI 检出时
`refs/remotes/origin/<本分支>` 往往已经等于 `after` —— 那个引用把自身排除了,范围恒为 **0 条**,
闸门直接失败(或更糟:被当成"没有要检查的提交"放过去)。
**根因**:拿"刚 fetch 下来的同名远端分支"当排除集。它是**被检出的那个分支**,不是基线。
**判据**:新分支的基准只能是**远端默认分支**(事件里的 `repository.default_branch`),
范围 = `refs/remotes/origin/<default>..<after>`;取不到基准 ⇒ **明确失败**,不许退化成整部历史或空集。
**修法**:`ci_gate.resolve_scope()`;`ci_checkout.sh --post-checkout` 负责把默认分支引用取下来。
`--self-test` 里有专门的回归用例(`origin/<本分支> == after` 时必须仍算出那几条提交)。
---
### trap-22|把事件里的 `total_commits` 当成"声明条数"
**症状**:新分支推送的真实事件里 Gitea 给的是 `total_commits=0`、`commits` 列表被截断(实测 5 条),
而 Git 范围算出 12 条。严判"声明 ≠ Git 范围"⇒ 门禁在**第一次真实 CI 就红了**,
红的原因与代码质量无关(本地夹具用的是"声明 6 条/列表 1 条"的自造形状,覆盖不到这一种)。
**根因**:`total_commits` 在 Gitea 的语义不是"这次推送的提交条数",**新分支推送时就是 0**
(等于"没声明")。把它当权威声明去比对,等于用一个不存在的约束判红。
**判据**:**Git 范围是唯一权威**。`total_commits > 0` 时才拿来交叉核对(不一致 ⇒ 拒绝,
覆盖"声明 6 条 / 列表 1 条 / Git 2 条"那一类);`<=0` 或缺失 ⇒ 视为未声明,
以 Git 范围为准,并把"负载条数不可信、列表被截断"**写进来源说明**里。
**修法**:`ci_gate.resolve_scope()`(`--self-test` 里有专门的 `total_commits=0` 分支)。
教训:负例要**照真实事件负载的形状**造,不能只造自己想的形状。