From 42f01c747889f0c6cf3cd4ea0284ada9ed34ceb3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:35:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=89=E5=A4=84"=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E5=AF=B9=E3=80=81=E4=BD=86=E4=BF=A1=E5=8F=B7=E5=81=87"?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=B1=81=E5=85=8D=E7=9A=84=E8=87=AA=E6=A3=80?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E3=80=81rm=20=E5=A4=B1=E8=B4=A5=E4=BB=8D?= =?UTF-8?q?=E6=8A=A5"=E5=B7=B2=E5=88=A0=E9=99=A4"=E3=80=81show=5Fdiff=20?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 通读后逐处指出,三条都实测复现: **1. `jsonTestOnlyChange`/`testOnlyDrift`/`runtimeOnly` 没有任何自检碰过。** 他的质疑成立:我上一封说"配了六个样本",那六个样本是**开发时的内联脚本、没进文件** (他读了全文,找不到——我核了,确实没有)。而这段逻辑是文件里**唯一一处"把红变成绿" 的代码**,也是唯一没有判据的代码,失效方向恰好是"比恒黄更坏"那个(假绿)。 已补 **4 条走真路径的样本**(真临时树 + `diffSummary(d,{repoDir,snapDir})`): ★只差 `scripts.test` ⇒ 不算运行时漂移且必须说出豁免了哪条键; ★`scripts.start` 变了 ⇒ 必须算运行时;★解析不了 ⇒ 不许豁免;★不传 ctx ⇒ 不豁免。 ★ 写样本时被一对**同义不同数**的字段绊住:断言 `runtimeDrift === 0` 却得到 1 —— `diffSummary` 报的是**原始**检测数,而 `checkHost` 自己又减了一遍豁免。 判据自己产出两个矛盾口径,与"注释里两组矛盾的写点计数"同族 ⇒ **统一**: `runtimeDrift` = 减掉豁免后的结论,被豁免的只出现在 `testOnly` 里; `checkHost` 删掉自算的 `runtimeOnly`。不传 ctx 时行为与旧版逐字相同。 **2. `prune-deploy-artifacts.sh` 的 `del()`:`rm` 的结果没人看。** 脚本是 `set -uo pipefail`(**无 -e**)⇒ `rm -rf` 失败(权限/只读挂载/immutable)后 照样打"删除 …(NMB)"、收尾汇总"**已删除 N 项,释放约 X MB**" ——**一次失败之后仍产出成功措辞**(与 `redeploy-plugin.sh` 里"被拒还打 [ OK ]"同族)。 已改:判失败即报错 `exit 2`;`removed`/`freed_kb` **只在成功后累加**;并加抽验 (`rm` 报成功但路径还在 ⇒ 报错),不信单一退出码。 ⚠️ **这一条的失败路径我没能实测**:本机以 root 跑,权限拦不住 `rm`; `unshare -r` 建只读挂载被拒(`/proc/self/uid_map: Permission denied`); tmpfs 无 `chattr +i`。要覆盖得在带 CAP_LINUX_IMMUTABLE 的环境用 immutable 文件, 或给 `del()` 加 `RM` 注入点。**我没有把"改过"说成"验过"。** **3. `check-shared-libs.sh` 的 `show_diff` 被 `set -e` 当场中止。** `cmp` 有差异返回 1 ⇒ `pipefail` 让函数返回 1 ⇒ 独立调用触发 `set -e` ⇒ **脚本当场死**。 实测(脚本级,fixture 树里造两处分叉:pi 与 zcode): 旧版:报告 1 处分叉、收尾汇总 0 次 新版:报告 2 处分叉、收尾汇总 0 次(收尾那句本来只在成功时打,见下) 两者退出码都是 1(判定一直是对的)—— 所以**光量退出码看不见这个 bug**, 正是 pi 说的"判定对、证据被截断"。而且旧版连 `fail=1` 都执行不到: 退出码 1 是 `set -e` 给的。加 `|| true` 后遍历跑完。 --- deploy/check-deploy-drift.mjs | 86 +++++++++++++++++++++++++++----- deploy/check-shared-libs.sh | 15 +++++- deploy/prune-deploy-artifacts.sh | 24 ++++++++- 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 293ef3f..f9ec110 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -199,13 +199,25 @@ export function jsonTestOnlyChange(repoText, snapText) { } export function diffSummary(d, ctx = {}) { + // `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。 + const testOnly = (ctx.repoDir && ctx.snapDir) ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) : []; + // ★ `runtimeDrift` **就是结论**:已经减掉被豁免的那些。 + // + // 原先它不减 —— 于是同一个概念有两个数:本字段("检测到多少个运行文件不同") + // 与 `checkHost` 自己算的 `runtimeOnly`("其中真正算漂移的")。 + // 我给豁免写自检样本时就被这对数绊了一下:断言 `runtimeDrift === 0` 得到 1, + // 一度以为豁免失效,其实是**两个字段名同义不同数**。 + // 判据自己产出两个互相矛盾的口径,与"注释里两组矛盾的写点计数"是同一族毛病, + // 所以在这里一次性统一:`runtimeDrift` = 真正的运行时漂移; + // 被豁免的部分**只出现在 `testOnly` 里** —— 看得见,但不再计入漂移。 + // + // ⚠️ 不下传 ctx 时 `testOnly` 为空 ⇒ `runtimeDrift` 与旧行为完全一致 + // (上面那些既有样本因此不受影响)。 return { - runtimeDrift: d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length, + runtimeDrift: + d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length - testOnly.length, docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length, - // `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。 - testOnly: (ctx.repoDir && ctx.snapDir) - ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) - : [] + testOnly }; } @@ -344,13 +356,10 @@ function checkHost(spec) { // ⚠️ 摘的条件很窄(`jsonTestOnlyChange` 只豁免 `scripts.test` 一类字段), // 而且只对"两边都在、仅内容不同"的文件生效;一侧独有的文件集变化照旧算运行时 —— // **把运行时差异误判成非运行时比恒黄更坏,那是假绿。** + // `runtimeDrift` 已经是"减掉豁免之后"的结论(见 diffSummary 的注释: + // 以前这里自己再算一遍 `runtimeOnly`,于是同一个概念有两个数)。 const testOnlyPaths = new Set(testOnly.map(t => t.path)); - const runtimeOnly = [ - ...d.onlyRepo.runtime, - ...d.onlySnap.runtime, - ...d.changed.runtime.filter(f => !testOnlyPaths.has(f)) - ]; - if (runtimeOnly.length === 0) { + if (runtimeDrift === 0) { const notes = []; if (testOnly.length) { notes.push(`${testOnly.length} 处非运行时差异(只差 ${testOnly.map(t => t.keys.join('/')).join('、')},生产不跑):${testOnly.map(t => t.path).join('、')}`); @@ -360,7 +369,7 @@ function checkHost(spec) { } else { fail( '① 运行文件与仓库一致', - `漂移 ${runtimeOnly.length} 处:${[ + `漂移 ${runtimeDrift} 处:${[ ...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`), ...d.onlySnap.runtime.map(f => `快照独有 ${f}`), ...d.changed.runtime.filter(f => !testOnlyPaths.has(f)).map(f => `内容不同 ${f}`) @@ -545,6 +554,59 @@ export function selfCheck() { const withTest = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); out.push({ name: 'test/ 不参与比较(与部署脚本一致)', ok: withTest.runtimeDrift === 1 }); + // ★ `jsonTestOnlyChange` / `testOnlyDrift` 的覆盖(pi 评审 2026-09-14 指出): + // **这一段逻辑是文件里唯一一处"把一个红变成绿"的代码,也是唯一没有判据的代码** —— + // 而它失效的方向恰好是"比恒黄更坏"的那个(假绿)。 + // 原先我只在开发时用内联脚本喂过六个字符串样本,**没进文件**(pi 通读全文找不到, + // 他的质疑成立)。现在补成**走真路径**的样本:真临时树 + `diffSummary(d, {repoDir, snapDir})`, + // 与上面那两棵树同形、成本一样低。 + // + // 关键:`testOnly` 只在**给出两棵树根目录**时才非空,所以样本必须传 ctx —— + // 否则它恒为 `[]`,"豁免"这件事永远没被走到(这正是原先没覆盖的原因)。 + // ⚠️ 先**把两棵树恢复成同形**:上面几条样本把 `b` 改过(`extra.mjs`、`README.md` + // 内容、还有只存在于 `a` 的 `test/`)—— 我第一版没复位,于是四条样本全红、 + // 红的原因还都不是我要测的那件事。**样本之间的相互污染**和"位置选择器"是同一族: + // 断言的语义被前面步骤悄悄改变了。所以这里显式复位到"两棵树只差 package.json"。 + rmSync(join(b, 'lib', 'extra.mjs'), { force: true }); + rmSync(join(a, 'test'), { recursive: true, force: true }); + writeFileSync(join(b, 'README.md'), 'doc'); + writeFileSync(join(a, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'OLD', start: 'S' } })); + writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'S' } })); + const testOnlyDrift1 = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b }); + out.push({ + name: '★只差 scripts.test ⇒ 不算运行时漂移(且必须说出豁免了哪条键)', + ok: + testOnlyDrift1.runtimeDrift === 0 && + testOnlyDrift1.testOnly.length === 1 && + testOnlyDrift1.testOnly[0].keys.includes('scripts.test') + }); + + // 反面:`scripts.start` 变了必须仍算运行时 —— 豁免过宽就是假绿。 + writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'CHANGED' } })); + const startChanged = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b }); + out.push({ + name: '★scripts.start 变了 ⇒ 必须算运行时(豁免不许过宽)', + ok: startChanged.runtimeDrift === 1 && startChanged.testOnly.length === 0 + }); + + // 反面:解析不了就不敢下结论(按运行时算)。 + writeFileSync(join(b, 'package.json'), '{ 不是 json'); + const unparsable = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b }); + out.push({ + name: '★package.json 解析不了 ⇒ 不许豁免(按运行时算)', + ok: unparsable.runtimeDrift === 1 && unparsable.testOnly.length === 0 + }); + + // 反面:**不传 ctx** 时 `testOnly` 必须为空 —— 否则"给出根目录才有豁免"这个前提 + // 会在别处悄悄变成"任何 diffSummary 都自动豁免"。 + writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'S' } })); + const noCtx = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ + name: '★不传 repoDir/snapDir ⇒ 不豁免(testOnly 为空)', + ok: noCtx.runtimeDrift === 1 && noCtx.testOnly.length === 0 + }); + rmSync(join(b, 'package.json'), { force: true }); + // 判据 ④ 的行为用例。 // // 这一组来自一次**自检没接住的真错**:`restartOnSwitch` 只有 dsh 显式设了 true, diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 9efa530..3369757 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -85,8 +85,21 @@ tests_for() { } # 差异明细也走 cmp,不借 diff。 +# +# ★ 末尾那个 `|| true` **是判据的一部分,不是风格**(pi 评审 2026-09-14 抓到,已实测复现): +# 本脚本是 `set -euo pipefail`,而 `cmp` 在"有差异"时返回 1 ⇒ `pipefail` 让这个函数 +# 也返回 1 ⇒ 调用点 `show_diff …` 是一句独立命令,`set -e` 于是**当场中止整个脚本**。 +# 实测形状(造一个分叉): +# 共用模块已分叉:f ← 打印了 +# ← 打印了 +# ② 本该继续遍历后续文件 ← **没打印**(脚本已死) +# ③ 本该收尾报"四方同源" ← **没打印** +# 也就是说:判定(红)是对的,但**证据被截成一条**,而且连 `fail=1` 都执行不到 +# —— 退出码 1 是 `set -e` 给的,不是那条赋值给的。 +# 一条判据红了,却只报第一个对象、不报"我比完了全部"—— 那正是这套评审反复讲的 +# "证据必须足以支持结论"。加 `|| true` 后,遍历跑完、汇总照打,红仍然是红。 show_diff() { - cmp "$1" "$2" 2>&1 | head -3 + cmp "$1" "$2" 2>&1 | head -3 || true } fail=0 diff --git a/deploy/prune-deploy-artifacts.sh b/deploy/prune-deploy-artifacts.sh index 6be9b9d..c7a5d12 100755 --- a/deploy/prune-deploy-artifacts.sh +++ b/deploy/prune-deploy-artifacts.sh @@ -81,7 +81,29 @@ del() { exit 1;; esac local kb; kb=$(du -sk "$path" 2>/dev/null | cut -f1); kb=${kb:-0} - if [ "$APPLY" = "1" ]; then rm -rf "$path"; fi + # ★ `rm` 的结果必须看:本脚本是 `set -uo pipefail`(**没有 -e**), + # 于是 `rm -rf` 失败(权限、只读挂载、immutable)之后脚本照样往下走, + # 先打"删除 …(NMB)"、收尾再汇总"**已删除 N 项,释放约 X MB**" —— + # **一次失败之后仍然产出成功措辞**(pi 评审 2026-09-14 抓到,实测确认)。 + # 这与 `redeploy-plugin.sh` 里"被拒之后照样打 `[ OK ]`"是同一族,也是本仓库 + # 反复记录的那个病:从失败里产出一份看着正常的报告。 + # 所以:判失败即报错并 `exit 2`(环境/权限问题),且 `removed`/`freed_kb` + # **只在成功后才累加**,收尾汇总不再虚报。 + if [ "$APPLY" = "1" ]; then + if ! rm -rf "$path"; then + printf ' + [FAIL] 删不掉:%s(权限/只读挂载/immutable?)\n' "$path" >&2 + printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会报"已删除"。\n' >&2 + exit 2 + fi + # 删完还要**抽验**:`rm -rf` 对某些情况会"成功"却留东西(例如非空目录里 + # 有不可删项时,某些实现返回 0)。存在即报错,别信退出码单一信号。 + if [ -e "$path" ]; then + printf ' + [FAIL] `rm -rf` 报了成功但 %s 还在\n' "$path" >&2 + exit 2 + fi + fi freed_kb=$((freed_kb + kb)); removed=$((removed + 1)) report " $([ "$APPLY" = 1 ] && echo 删除 || echo 待删)" "$path($((kb/1024))MB)" }