mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 伤在哪c20e7c6把 ci.yml 的空表守卫写成了: if [ "$n" -eq 0 ] && false && ! grep -qEi '...# none...' <<< "$TABLE"; then ^^^^^^^^ `&& false` 让整个条件恒假 ⇒ ID:19 的「空表必须显式声明」守卫**永不触发**。 这比没有守卫更坏:门禁*看起来*有了守卫,实际恒绿。599252f 引入时是正确的 (无 `&& false`),是c20e7c6把它改坏的。 ## 改了什么(两处,无夹带) 1. .github/workflows/ci.yml —— 只删 `&& false` 这一个 token,其余逐字不动。 守卫恢复为「空表且未写 # none ⇒ 报错退出 1」。 2. .github/scripts/check-ci-gates.py —— 在599252f已有的正反自测之上**加一层**: · `_rejected_checked[0] != 4` 调用计数契约:把四条 `_must_reject(...)` 调用 整段删掉/注掉时,前面所有断言代码都还在、但一次都不会跑(自测静默死掉、 门禁照绿,变异 M8d 实测 exit=0)。现在删一行也红。 · `idx >= len(_EMPTY_BAD_CASES)` 越界可读报错:样本集被清空时不再抛 IndexError 崩溃,给出可读断言消息。 与599252f**不冲突、不抵消**:599252f 加的是「空表标记校验 + 正反自测」, 本提交是在其之上补的「自测自身是否真的跑了」。 ## 实证(提交前实跑) · grep -n '&& false' .github/workflows/ci.yml → 0 命中(exit=1) · python3 .github/scripts/check-ci-gates.py → EXIT=0「CI 门禁自检通过」 · yaml.safe_load → yaml OK(exit=0) · 反向:把 heredoc 数据行清空只留空行 → 门脚本 EXIT=1, 报「排除表步骤用当前 ci.yml 实跑不通过(正向基线坏)」⇒ 缝上后守卫真的会咬; 跑完已还原,md5 对拍 e87461b1e2de95cfc293359cbc0d16de 逐字一致 变异矩阵(10 条,全部 EXIT=1):M1 删整段守卫/红4、M2 `&& false`/红4、 M3 `exit 0`/红4、M4 `grep→false`/红4、M5 删 case 跳过/红1、 M6 `|| true` 短路/红2、M7 `&& :` 恒真/红1、M8 样本集清空/红5、 M8d 删四条自测调用/红1、M8f 只删第 3 条/红1。基线 EXIT=0 红条数 0。 Co-authored-by: pi <pi@local>
313 lines
16 KiB
Python
Executable File
313 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""CI 门禁自检:断言 ci.yml 的关键机制仍在。退出码 0 = 通过。
|
||
|
||
## 为什么用 YAML 解析,而不是 grep 文本
|
||
|
||
本次改动的注释里**刻意保留了旧写法的引述**(例如
|
||
「原先这里写死 ./internal/agent/core/ ./cmd/waiter/ 两个包」)。
|
||
于是 grep 会两头骗人:
|
||
· 断言「旧写法不存在」→ 注释里那句引述让它**假红**
|
||
· 断言「新机制存在」 → 注释里写一句同名机制就能让它**假绿**
|
||
yaml.safe_load 丢掉全部注释,只留可执行内容 ⇒ 两类误判同时消失。
|
||
|
||
## 这条判据防的是什么
|
||
|
||
CI 配置是「静默退化」的高发区:把动态枚举改回写死包列表,
|
||
流水线照样绿(只是罩得少了),没人会发现。所以必须有人盯着
|
||
「动态枚举还在」「go test 真的吃了枚举结果」这两件事。
|
||
"""
|
||
import sys
|
||
|
||
import yaml
|
||
|
||
PATH = ".github/workflows/ci.yml"
|
||
fails = []
|
||
|
||
|
||
def check(cond, msg):
|
||
if not cond:
|
||
fails.append(msg)
|
||
|
||
|
||
with open(PATH, encoding="utf-8") as f:
|
||
doc = yaml.safe_load(f)
|
||
|
||
jobs = (doc or {}).get("jobs") or {}
|
||
check(bool(jobs), "YAML 解析后没有 jobs —— 文件结构坏了")
|
||
|
||
LEGACY_RACE_PKGS = "./internal/agent/core/ ./cmd/waiter/"
|
||
|
||
|
||
def code_lines(run):
|
||
"""只保留**可执行**行:剥掉 shell 注释。
|
||
|
||
★ 为什么必须这么干(本条判据自己栽过的坑):
|
||
YAML 解析只丢掉了 YAML 级注释,但 `run: |` 块里的 `# ...`
|
||
是**字符串内容**,照样落在 run 文本里。
|
||
于是 `"cmd/gui" in run` 会被一句说明性注释满足 ——
|
||
真把 `grep -v '^.*/cmd/gui$'` 删掉,断言仍然绿。
|
||
判据自己被注释骗过去,和被测代码骗判据是同一类错误。
|
||
"""
|
||
out = []
|
||
for line in run.splitlines():
|
||
if line.lstrip().startswith("#"):
|
||
continue
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
def step_runs(job):
|
||
out = []
|
||
for st in (jobs.get(job) or {}).get("steps") or []:
|
||
run = st.get("run")
|
||
if run:
|
||
out.append((st.get("name") or "", run))
|
||
return out
|
||
|
||
|
||
# ── 1. race job:包列表必须动态枚举,并真的剔除 cmd/gui ──
|
||
race = step_runs("race")
|
||
enum = [(n, r) for n, r in race if "go list ./..." in r and "GITHUB_OUTPUT" in r]
|
||
check(len(enum) == 1, "race job 应恰有一步用 `go list ./...` 动态枚举包(实际 %d 步)" % len(enum))
|
||
if enum:
|
||
# ★ 用 code_lines 剥掉 shell 注释后再断言,否则注释里的 "cmd/gui" 会顶包
|
||
check("cmd/gui" in code_lines(enum[0][1]),
|
||
"race 枚举步骤必须显式剔除 cmd/gui(否则 go test 报 no Go files 的假红)")
|
||
|
||
# ── 2. 真正的 go test -race 必须吃枚举结果,不许写死包列表 ──
|
||
#
|
||
# ★ 只认「全仓主步骤」:race job 里还有一步是**单包取证**(只跑 webui 那一条
|
||
# 已知竞争),它本来就不该吃全仓枚举。初版脚本没区分这两类,把取证步骤
|
||
# 也套上「必须吃枚举」的断言 ⇒ 误报红。判据自己误报同样是缺陷(会让人
|
||
# 习惯性忽略红),所以这里按「步骤是否消费 PKGS 变量」来分流。
|
||
main_race = [(n, r) for n, r in race
|
||
if "go test -race" in r and "$PKGS" in r]
|
||
check(len(main_race) == 1,
|
||
"race job 应恰有一步全仓 go test -race 消费 $PKGS(实际 %d 步)" % len(main_race))
|
||
for _n, r in main_race:
|
||
check("steps.pkgs.outputs.pkgs" in code_lines(r),
|
||
"全仓 go test -race 必须使用枚举结果 steps.pkgs.outputs.pkgs")
|
||
check(LEGACY_RACE_PKGS not in code_lines(r),
|
||
"全仓 go test -race 里出现了旧的写死包列表 " + LEGACY_RACE_PKGS)
|
||
|
||
# 取证步骤:允许单包跑,但必须写明修复单号(否则排除项成了无主的掩盖)
|
||
evid = [(n, r) for n, r in race if "go test -race" in r and "$PKGS" not in r]
|
||
for _n, r in evid:
|
||
check("DATA RACE" in r,
|
||
"取证步骤应显式检查 DATA RACE 报告数(否则取证没意义)")
|
||
|
||
# ── 3. skip 排除表:真跑一次坏输入,要求它必须失败 ──
|
||
#
|
||
# ★ 这里换过三版,每一版的失效方式都值得记:
|
||
# v1 `any("没有写原因" in r ...)` —— 只查提示文案;把 if 条件改 false 仍绿
|
||
# v2 剥注释后查 `${reason// /}` + `exit 1` —— 两个 token 都在仍可能不成立;
|
||
# 实测 `if [ -z "${reason// /}" ] && false; then` 依然绿
|
||
# v3 真跑、但拿真实 webui 那一行做坏输入 —— 该排除项修好被删掉时**误报红**
|
||
# v4(本条)用**合成行**注入 TABLE 再真跑:
|
||
# · 不依赖任何真实排除项 ⇒ 排除项增删不会引起误报
|
||
# · 真跑 ⇒ `&& false` 这类「形似而无效」的改动会被抓出来
|
||
# v5(ID:19)堵空表假绿:v4 的 `(.*?)` 非贪婪且允许空串 ⇒ 排除表被删到
|
||
# 空串时捕获体为空,下游只看 "$n" -eq 0 就静默走「无排除项」分支,
|
||
# 排除项漏删/误删永远不会被任何人发现。现在要求**空表必须显式声明**
|
||
# (heredoc 里保留一行 `# none`),且下列三条各自有只违反它的坏输入做自测。
|
||
#
|
||
# 两条守卫必须**各自**用只违反它的输入来验,否则一条会顶另一条的包
|
||
# (第一版我把 reason 与 ticket 同时清空,ticket 守卫兜住了,reason 守卫死没死看不出来)。
|
||
import subprocess as _sp
|
||
import tempfile as _tf
|
||
|
||
# 空表显式标记:与 ci.yml 里的判据同一串(改一处必须同时改另一处)。
|
||
_EMPTY_MARK = "# none"
|
||
|
||
_tbl = None
|
||
for _n, _r in race:
|
||
if "计算 -race 排除表并打印" in (_n or ""):
|
||
_tbl = _r
|
||
if _tbl is None:
|
||
fails.append("race job 找不到「计算 -race 排除表并打印」步骤 —— 无法自测排除表守卫")
|
||
elif "${{" in _tbl:
|
||
fails.append("排除表步骤含 GitHub 表达式,本脚本无法本地执行它(需改用别的验证方式)")
|
||
else:
|
||
import re as _re
|
||
_m = _re.search(r"read -r -d '' TABLE <<'EOF'[^\n]*\n(.*?)\nEOF\n", _tbl, _re.S)
|
||
if _m is None:
|
||
fails.append("排除表步骤里找不到 TABLE heredoc —— 无法注入合成坏行")
|
||
else:
|
||
def _run_body(body):
|
||
fd, tmp_sh = _tf.mkstemp(suffix=".sh")
|
||
with __import__("os").fdopen(fd, "w", encoding="utf-8") as f:
|
||
f.write(body)
|
||
out = _tf.mktemp()
|
||
env = dict(__import__("os").environ)
|
||
env["GITHUB_OUTPUT"] = out
|
||
try:
|
||
return _sp.run(["bash", tmp_sh], capture_output=True, text=True, env=env)
|
||
finally:
|
||
__import__("os").unlink(tmp_sh)
|
||
if __import__("os").path.exists(out):
|
||
__import__("os").unlink(out)
|
||
|
||
def _inject(line):
|
||
inner = _m.group(1)
|
||
new_inner = (line + "\n" + inner) if inner.strip() else line
|
||
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
|
||
|
||
def _replace_table(new_inner):
|
||
"""把 heredoc 的捕获体整段替换成 new_inner(用来造各种空表形态)。"""
|
||
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
|
||
|
||
# 正向:原样必须通过
|
||
if _run_body(_tbl).returncode != 0:
|
||
fails.append("排除表步骤用当前 ci.yml 实跑不通过(正向基线坏)")
|
||
|
||
# ── 正向:合法空表(显式 `# none` 标记)必须通过 ──
|
||
# ★ 这是"排除表可以合法为空"的必要性证明:最后一条 pkg 行被删掉后,
|
||
# 表本来就该空,不能一空就红。
|
||
if _run_body(_replace_table(_EMPTY_MARK)).returncode != 0:
|
||
fails.append("合法的空表(显式 `# none` 标记)竟被拒 —— 空表本应被允许,"
|
||
"守卫不能把「排除项删干净」与「漏写标记」混为一谈")
|
||
|
||
# ── 反向:空体没有任何标记 ⇒ 必须红(这就是 ID:19 的假绿入口)──
|
||
#
|
||
# ★ 自测自身的可被改坏性(ID:19 变异 M8/M8d 抓出来的缺口):
|
||
# 初版把四条反向样本放在一个 for 循环里。把容器改成 `[] and [...]`
|
||
# 或 `[] or [...]` 后循环空转、一条都不跑,而**没人发现**
|
||
# —— 实测那份变异下本脚本 exit=0、零失败(自测静默死掉)。
|
||
# 补一道"旁路重算 _ran"也不够:M8d 证明旁路能正常数出 4/4,
|
||
# 而真正做判断的那段已被空转(两份逻辑脱钩了)。
|
||
#
|
||
# ⇒ 结论:**不用可被改坏的容器驱动断言**。把四条显式展开,
|
||
# 样本集只当"取值源"用(下标直取),不存在"改坏 for 容器"这个面。
|
||
# 这样只剩两种可能:样本被删(len 断言报)或某条真的没咬住(逐条报)。
|
||
_EMPTY_BAD_CASES = [
|
||
("完全空体", ""),
|
||
("只有空行", "\n"),
|
||
("只有空行+空白", "\n \n"),
|
||
("非显式标记的注释", "# 无排除项"),
|
||
]
|
||
if len(_EMPTY_BAD_CASES) != 4:
|
||
fails.append("空表反向样本数变了(期望 4,实际 %d)—— 除新增样本,否则说明样本被删"
|
||
% len(_EMPTY_BAD_CASES))
|
||
|
||
_rejected_checked = [0] # 用 list 当可变计数(闭包内可写)
|
||
|
||
def _must_reject(idx):
|
||
"""断言第 idx 个空表形态**必须**被拒。"""
|
||
_rejected_checked[0] += 1
|
||
if idx >= len(_EMPTY_BAD_CASES):
|
||
# 样本被删:不抛 IndexError,给可读报错(崩溃不如可读断言好)。
|
||
fails.append("空表反向样本集缺第 %d 条(只剩 %d 条)—— 样本被删或容器被改坏"
|
||
% (idx, len(_EMPTY_BAD_CASES)))
|
||
return
|
||
_n, _i = _EMPTY_BAD_CASES[idx]
|
||
if _run_body(_replace_table(_i)).returncode == 0:
|
||
fails.append(
|
||
"空表形态「%s」竟通过 ⇒ 空表显式声明守卫已失效"
|
||
"(表被删空与有意清空无法区分,排除项漏删会静默变绿)" % _n)
|
||
|
||
_must_reject(0) # 完全空体
|
||
_must_reject(1) # 只有空行
|
||
_must_reject(2) # 只有空行+空白
|
||
_must_reject(3) # 非显式标记的注释
|
||
|
||
# ★ 四次调用本身也必须被核对(ID:19 变异 M8d):
|
||
# 把上面四行 `_must_reject(...)` 整段删掉/注掉时,前面所有断言代码都还在,
|
||
# 但**一次都不会跑** —— 自测静默死掉、门禁照绿(实测 exit=0)。
|
||
# 所以把调用次数当成一份合同:删一行也红,不会默默少检。
|
||
if _rejected_checked[0] != 4:
|
||
fails.append("空表反向自测只调用了 %d/4 次 —— 有检查被删/跳过(自测静默失效)"
|
||
% _rejected_checked[0])
|
||
|
||
_probe = "test|internal/probe/synthetic|TestSyntheticProbe"
|
||
# 只打 reason:reason 空、ticket 有效
|
||
if _run_body(_inject(_probe + "||PROBE-TICKET")).returncode == 0:
|
||
fails.append("合成行「reason 空 / ticket 有效」竟通过 ⇒ reason 守卫已失效"
|
||
"(形似而无效的改动,例如 if 条件加 && false)")
|
||
# 只打 ticket:reason 有效、ticket 空
|
||
if _run_body(_inject(_probe + "|合成探针原因|")).returncode == 0:
|
||
fails.append("合成行「reason 有效 / ticket 空」竟通过 ⇒ ticket 守卫已失效")
|
||
|
||
# ── 4. go job:整机启动图判据必须单列一步跑 kernelmod/all ──
|
||
#
|
||
# ★ 目标包随 ID:13 从 internal/kernelboot/modules(死包,已删)迁到
|
||
# internal/kernelmod/all(真实聚合入口)。本断言跟着改,否则它盯的
|
||
# 是一个**已不存在的包** —— 那会变成"永远红"或(更糟)"永远绿"的空转。
|
||
GO_BOOT_PKG = "./internal/kernelmod/all/"
|
||
gojob = step_runs("go")
|
||
boot = [r for _n, r in gojob if GO_BOOT_PKG in r and "go test" in r]
|
||
check(bool(boot), "go job 缺少「整机启动图判据」步骤(go test " + GO_BOOT_PKG + ")")
|
||
for r in boot:
|
||
check("-count=1" in r, "整机启动图判据步骤必须带 -count=1(不许吃 go test 缓存)")
|
||
|
||
# ── 5. cross job:cmd 清单必须实测枚举,不许写死 ──
|
||
cross = step_runs("cross")
|
||
pure = [r for _n, r in cross if "CGO_ENABLED=0 go build" in r and "GITHUB_OUTPUT" in r]
|
||
check(bool(pure), "cross job 缺少「枚举可纯交叉编译的 cmd」步骤")
|
||
builds = [(n, r) for n, r in cross if "go build -trimpath" in r]
|
||
check(bool(builds), "cross job 里没有真正的 go build 步骤")
|
||
for _n, r in builds:
|
||
check("steps.pure.outputs.cmds" in r,
|
||
"交叉构建必须吃枚举结果 steps.pure.outputs.cmds,不许写死 cmd 列表")
|
||
|
||
# ── 5.5 code_lines 自测:这是「注释顶包」的唯一防线,必须自己也被验 ──
|
||
#
|
||
# ★ 为什么需要这段自测(M7 的教训):
|
||
# 原先把「code_lines 不再剥注释」当成一条变异,但它**单独注入时不会红** ——
|
||
# 因为 ci.yml 完好时,`grep -v cmd/gui` 真实存在,剥不剥注释都能匹配上。
|
||
# 那是**空变异**(行为没变,不可能红),拿它当判据缺口是误判。
|
||
# 正确做法不是造更狠的变异,而是让 code_lines 的行为**自己可被验证**:
|
||
# 喂一个"cmd/gui 只出现在注释里"的样本,要求剥离后断言为假。
|
||
# 这样把 code_lines 改成不剥注释,本条立刻失败 —— 单文件、真行为变化。
|
||
_probe = "#!/bin/bash\n# cmd/gui 只是注释里的说明\necho hi\n"
|
||
if "cmd/gui" in code_lines(_probe):
|
||
fails.append("code_lines 没剥掉 shell 注释 —— cmd/gui 断言会被注释顶包(假绿)")
|
||
_keep = "#!/bin/bash\nALL=$(go list ./... | grep -v '^.*/cmd/gui$')\n"
|
||
if "cmd/gui" not in code_lines(_keep):
|
||
fails.append("code_lines 把可执行行也删了 —— 断言会假红")
|
||
|
||
# ── 6. pkg 级排除(整包不进 -race):必须带修复单号 + 兜底断言 ──
|
||
#
|
||
# 整包排除是有代价的:该包所有用例都不再受竞争检测保护。
|
||
# 因此要求:① 排除表每项必须有修复单号;② 剔除后包数有下限兜底,
|
||
# 不许"排除逻辑写坏了导致静默少测";③ 断言排除项确实从待测列表消失。
|
||
allrace = "\n".join(r for _n, r in race)
|
||
if "KERNEL-REGISTRY-SETLOCK" in allrace or "pkg" in allrace:
|
||
check("修复单" in allrace or "修复单号" in allrace,
|
||
"pkg 级排除必须写明修复单号(否则成了无主的静默掩盖)")
|
||
check("-lt 50" in allrace or "lt 50" in allrace,
|
||
"剔除后必须有包数下限兜底(防止排除逻辑写坏导致静默少测)")
|
||
check("仍出现在待测列表里" in allrace or "剔除逻辑失效" in allrace,
|
||
"必须断言被排除的包确实从列表消失(防止写了排除但没生效)")
|
||
|
||
# ── 7. 每个 job 的每个 run 步骤:bash 语法必须过 ──
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import tempfile
|
||
|
||
for jn, job in jobs.items():
|
||
for st in job.get("steps") or []:
|
||
run = st.get("run")
|
||
if not run:
|
||
continue
|
||
body = re.sub(r"\$\{\{[^}]*\}\}", "PLACEHOLDER", run)
|
||
fd, p = tempfile.mkstemp(suffix=".sh")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||
f.write(body)
|
||
r = subprocess.run(["bash", "-n", p], capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
first = (r.stderr or "").strip().splitlines()
|
||
fails.append("shell 语法错误 @ %s / %s: %s"
|
||
% (jn, st.get("name"), first[0] if first else "?"))
|
||
finally:
|
||
os.unlink(p)
|
||
|
||
if fails:
|
||
print("CI 门禁自检失败:")
|
||
for f in fails:
|
||
print(" x " + f)
|
||
sys.exit(1)
|
||
|
||
print("CI 门禁自检通过:动态枚举 / 排除表校验 / 启动图判据步骤 / 交叉枚举 / 全部 run 步骤语法")
|