Files
knowledge-governance/docs/agents/traps.md
姚章浩 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

20 KiB
Raw Permalink Blame History

已知陷阱(全文)

这份文件是 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 分支)。 教训:负例要照真实事件负载的形状造,不能只造自己想的形状。