fix: 三处"判定对、但信号假"——豁免的自检覆盖、rm 失败仍报"已删除"、show_diff 截断证据
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` 后遍历跑完。
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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 ← 打印了
|
||||
# <cmp 头 3 行> ← 打印了
|
||||
# ② 本该继续遍历后续文件 ← **没打印**(脚本已死)
|
||||
# ③ 本该收尾报"四方同源" ← **没打印**
|
||||
# 也就是说:判定(红)是对的,但**证据被截成一条**,而且连 `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
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user