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)" }