机械自检脚本:cite + state 两类可判定错误,走 CI 强制 - #54
Conversation
从 PR-Daemon 一千余轮 review 记录里按类聚合,只有 cite / state 两类是判错了 一定是脚本 bug 而不是口味问题。高频的另外三类(测试不承重 35.1%、常量半扫 5.4%、set -e 吞退出码 6.5%)都需要语义判断,放进来必然误报——一个被普遍 --no-verify 掉的闸门比没有闸门更糟,它让人以为已经检查过了。 cite 两个方向都扫:新增行引不存在的路径,以及本次删掉的文件仍被存量 md 引用。 只做前者会漏掉最典型的一类(实测在 airaccount-contract#199 上漏过)。 走 CI 不走 pre-commit hook:后者一句 --no-verify 就绕过去了。
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 2a23b653 首审 [4-round]
利益冲突,先说清楚:这个 PR 的内容来自 PR-Daemon 自己的 review 记录,脚本形态也是我出的,
上一轮 watcher 因此把它标成了SKIPPED_SELF_AUTHORED。所以下面没有一条是"我觉得" ——
全是可复跑的机械证据(探针文件 + 实跑输出 + CI 实况),口味类意见一条都不提。
最硬的一条是 GitHub 自己给的:这个闸门在它自己的 PR 上是红的。
🔴 [Blocking] 闸门在自己身上就是红的,而且红在一行注释上
$ gh api .../commits/2a23b653/check-runs
selfcheck completed failure ← 不是我构造的,是 PR 现在的真实状态
$ cd <PR head> && bash scripts/pr-selfcheck.sh --base $(git merge-base HEAD origin/main)
FAIL scripts/pr-selfcheck.sh:113 有 `jq … > *.tmp` 后直接 mv,缺 && 串联或 -s 非空守卫
汇总 FAIL=1 rc=1
$ sed -n '113p' scripts/pr-selfcheck.sh
# `jq … "$F" > "$F.tmp"; mv "$F.tmp" "$F"` 里,jq 失败时 shell 已经把 .tmp 建成空文件,
第 113 行是这个检查自己的说明注释。 state 那段完全没有注释过滤,所以任何文档化了这个反模式的
shell 文件都会被判成缺陷 —— 包括写它的那个文件。
这条不是"上线后再迭代"能扛过去的,因为它直接踩中脚本自己第 7-8 行写的那句话:
误报……第一天就会被
--no-verify/[skip ci]绕过去,那比没有更糟 —— 一个被普遍绕过的闸门
会让人以为已经检查过了。
第一个 PR 就误报,而且误报的是它自己。
🔴 [Blocking] state 的判据是词法共现,不是它声称的守卫关系 —— 实测三个场景全判反
第 126-127 行的实际控制流是「窗口三行内出现任意 && 或 [ -s ] → 无条件放行;否则出现任意 mv → 报错」。
它从不要求那个 && 串联的是这一对 jq/mv,也不要求 -s 检查的是对应那个 tmp。
我用一个文件同时打三发:
1 #!/bin/bash
2 # 场景1:真 bug,但窗口内有一行无关的 &&
3 jq '.a=1' "$F" > "$F.tmp"
4 [ -n "$X" ] && echo "无关的一行" ← 与这次写入毫无关系
5 mv "$F.tmp" "$F"
6
7 # 场景2:真 bug,但 mv 在 +3 行
8 jq '.b=1' "$G" > "$G.tmp"
9 echo pad1
10 echo pad2
11 mv "$G.tmp" "$G"
12
13 # 场景3:纯注释里提到这个反模式(不该报)
14 # 千万别写 jq . x > x.tmp 然后 mv x.tmp x实跑:
FAIL t.sh:14 ← 注释,误报
(第 3 行:漏) ← 真 bug
(第 8 行:漏) ← 真 bug
真 bug 0/2,误报 1/1。 而第 116 行写的是「判据只有一条:mv 之前有没有 && 串联或 -s 非空检查。
零解释空间」—— 实现和这句话不是同一件事。
场景 1 一点也不刁钻:任何在写状态文件前后有一行 [ -f x ] && ... / [ "$v" = y ] && ... 的脚本都会中招,
而这类行在状态机脚本里到处都是。场景 2 更常见 —— 中间隔一行 chmod 或 sync 就超出 3 行窗口了。
Codex PK 独立复核结论:CONFIRM(攻不动),并给了一个比我更准的说法:
它实现的是词法共现,不是注释声称的守卫关系。两次
grep没有要求&&串联的是目标jq与
目标mv、-s检查的是对应 tmp、mv搬运的是对应 tmp —— 三者甚至不需要位于可执行代码中。
Codex 也替我否掉了「注释误报是有意为之(想连文档里的坏示范一起禁)」这个辩护:检查对象被明确限定成
*.sh|*.bash、根本不扫 markdown,所以那个解释与实现范围不符。
可以怎么修(我把它跑通了,不是纸上建议)
窗口 + 关键字这个形状本身是病根 —— 我第一版修复(把窗口从 3 行放宽到 8 行)自己又漏了一发:
它捞到了下一个代码块的 [ -s ] 当成守卫。放宽窗口只是把假阴换个地方。
真正跑通的形状是按 tmp 路径配对:从 jq 行提取那个 tmp token,向前找引用同一 token 的行,
只在这两行之间判守卫;顺带跳过注释行、且守卫分支要先于 mv 分支判(否则 [ -s "$H.tmp" ] && mv … 这种
安全写法会被误报 —— 这是我第二版踩的坑)。四个用例全对:
t.sh → 3, 8 红 14(注释) 不红 ✅
u.sh → 只有第 1 行红 && 串联、[ -s ] 两种安全写法都不红 ✅
本 PR 的 pr-selfcheck.sh → 0 条 ✅
最小正控 jq→tmp / mv → 红 ✅
🟠 [Medium] cite 方向①:basename 回退被无条件套到了完整路径上
第 79 行「散文里常只写文件名不写完整路径,仓内存在同名文件就不算悬空」这个回退是对的,但它对
带斜杠的完整路径也照样生效。实测:
① `docs/completely/made/up/README.md` (纯编造,但 basename 全仓有同名) → 漏 ❌
② `docs/made-up/nowhere-xyz.md` (basename 全仓无同名) → 报 ✅
于是凡是编造路径时用了常见文件名(README.md / index.ts / package.json / config.json),一律漏
—— 而这恰恰是 PR 描述里说方向①存在的理由(「新写的文档里编造的路径」)。
修法是一行:回退只在 $p 不含 / 时才走。
- git ls-files "*/$bn" "$bn" 2>/dev/null | grep -q . && continue
+ case "$p" in */*) ;; *) git ls-files "$bn" 2>/dev/null | grep -q . && continue ;; esac🟡 [Low] cite 只认反引号,markdown 链接形式不扫
③ [看这里](scripts/does-not-exist-at-all.sh) → 漏
④ 见 ./scripts/also-missing-zzz.sh → 漏
第 74 行的正则要求两侧反引号。而 md 里引用文件最常见的写法恰恰是链接。这条我不当阻塞
(可以是有意的取值范围),但建议在脚本头注释里写明"只扫反引号内的引用" —— 否则第一个被漏掉的人
会以为闸门覆盖了链接。
ℹ️ 顺带:R1b 报的 workflow 注入是假阳,但方向对
DeepSeek 安全轮报 ${{ github.base_ref }} 插进 run: 是脚本注入。这个模式类别是对的,但落错了字段:
base_ref 只能是目标仓库里已存在的分支名,fork 的 PR 作者控制不了它;真正可被攻击者控制的是
head_ref(fork 分支名)和 pull_request.title。本 workflow 没用到那两个,所以现状安全。
不阻塞,但值得记一笔:如果以后往这个 run: 里加 head_ref,就变成真洞了。
我认可的部分
- 双向扫描的论证是对的,而且理由有实测支撑(只做方向①会漏「存量文档 + 本次删掉被引文件」)。
- 「不扫全仓存量」是正确取舍 —— 26 报 25 误的话闸门第一周就死了,这个判断和上面 Blocking 那条
是同一个道理,只是这个 PR 自己在state上没做到。 - 短 SHA 要求含
a-f字母 防住 chainId 纯数字误报,这类细节是踩过才知道的。 - 走 CI 不走 pre-commit 的理由成立,
fetch-depth: 0也确实带上了(少了它merge-base会静默退化成
"无改动 → exit 0",闸门就变成永远绿的摆设 —— 这一点作者已经注意到了)。
结论:方向对、取舍对,但两个检查里目前有一个的判据与它要表达的语义没有可靠对应,而且第一个 PR 就
误伤了自己。 把 state 换成 tmp-token 配对(上面已跑通)、cite 的 basename 回退加上"仅无斜杠"限定,
就可以重跑。
各轮结论
| 轮次 | 模型 | 结果 |
|---|---|---|
| R1a | deepseek-v4-flash | 2 条,均未命中要害;且明确写下 "The state check correctly guards against empty-file overwrites" —— 与实跑相反 |
| R1b | deepseek-v4-flash | 1 条 workflow 注入:模式类别正确,字段判错(base_ref 非攻击者可控) |
| 核验 | 本轮主体 | 3 个 state 场景探针 + 4 个 cite 引用形态探针 + 在真 PR head 上实跑 + CI check-runs 实况 + 两版候选修复(第一版自己也漏,第二版四例全对) |
| R3 PK | Codex gpt-5.6-sol |
CONFIRM(攻不动);四个攻击角度逐条否掉,并把结论精确化为"词法共现而非守卫关系" |
| 裁决 | Sonnet | REQUEST_CHANGES |
机械证据:worktree HEAD 2a23b653 与 REST pulls/54 核对一致;在真 PR head + 真 merge-base
(ae441b23)上实跑 → FAIL=1 rc=1,命中行经 sed -n 113p 确认为注释;GitHub check-runs 实测
selfcheck completed failure;state 三场景探针(2 真 bug + 1 注释)实跑输出如上;cite 四形态探针
(编造完整路径/编造罕见路径/md 链接/相对路径)实跑输出如上;候选修复两版分别在
t.sh/u.sh/本 PR 脚本自身/最小正控 四个用例上回归。所有探针跑在 scratch 仓库与一次性 worktree 里,
Brood 主 checkout 未改动,worktree 已清理。
PR-Daemon v4 · 4-round · 本 PR 内容源自本 daemon 自身的 review 记录,冲突已在开头声明
从 PR-Daemon 一千余轮 review 的记录里按错误类别聚合,只挑出判错了一定是脚本 bug、而不是口味问题的两类落地:
citestatejq … > tmp; mv缺&&或-s守卫故意没收的三类(尽管出现频率更高):测试存在但不承重 35.1%、常量跨文件只改一处 5.4%、
set -e下退出码被吞 6.5%。它们都需要读懂业务才能判,放进来必然误报——一个被普遍--no-verify掉的闸门比没有闸门更糟,它让人以为已经检查过了。作用域是双向的
cite既扫「新增行引了不存在的路径」,也扫「本次删除/改名的文件仍被存量 md 引用」。只做前者会漏掉最典型的一类:引用行是存量、被引文件是本次删掉的——实测在 airaccount-contract#199 上就是这么漏的。两边都不扫全仓存量,否则每个 PR 把仓库历史债务重报一遍(实测 26 报里 25 条与本次改动无关)。为什么是 CI 不是 pre-commit
--no-verify一句就绕过去,而且要每人各装一次、装了不会更新。CI 每个 PR 强制一次,升级只改这里一处。本地想提前跑:bash scripts/pr-selfcheck.sh。实测
yaml.safe_load真解析过,不是只跑了步骤命令jq→tmp→mv→exit 1;带&&的同一写法 →exit 0set -u(while read到 EOF 时会误退出)——注释里写明了,这正是state要抓的同一种毛病不要 merge,等你确认。