"""变异检查:把"motor/start 许可判定"逐条退回旧形状,门槛必须变红。 存在理由:`node scripts/test_ecat_autostart.mjs` 全绿只说明"当前实现满足了断言", 不说明断言有灵敏度 —— 本项目反复栽在"测试绿着但判据永不触发"(tx 冻结守卫、 t8_soak 反极性都是同族)。这份脚本把实现改坏,看测试是否变红;只要有一条仍全绿, 那条断言就是自证的空话,禁止当成验收凭据。 它会**原地改写**两个 JS 文件再逐字节还原,所以要求这两个文件在工作树里是干净的 (有自己的未提交改动时先 commit 或 stash,别在这儿叠)。 用法:python scripts/check_fe_guard_mutation.py [--keep-report] 退出码:0=每条变异都被测出(且还原后基线仍绿) 1=有空断言或还原失败 2=前置不满足 """ import os import re import subprocess import sys ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) BUS = os.path.join(ROOT, "js", "common", "ecat-bus.js") TR = os.path.join(ROOT, "js", "motor", "transport-ecat.js") TEST = os.path.join("scripts", "test_ecat_autostart.mjs") FAIL_MARK = chr(10007) # ✗ SUMMARY = "通过 (\\d+) 项,失败 (\\d+) 项" # (说明, 文件, 原文, 改成的旧形状) MUTS = [ ("healthCheck 不再走 repairEcatDc(退回什么都不自愈)", TR, " await repairEcatDc(api, h.raw);", " /* MUTATED */"), ("proveEcatDc 把缺字段当成配错(7ea80f0 内联副本的形状)", BUS, ' if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";', " /* MUTATED */"), ("去掉自动重启的最小间隔", BUS, "export const AUTO_START_MIN_INTERVAL_MS = 20000;", "export const AUTO_START_MIN_INTERVAL_MS = 0;"), ("去掉「native 正在全量重连时不动总线」", BUS, " if (h.recovering) return null;" + chr(10) + " return ecatPost", " /* MUTATED */" + chr(10) + " return ecatPost"), ("proveEcatLoop 把「读不到」当成 down(抹掉 unknown 三态)", BUS, ' if (!h || h.ok !== true) {' + chr(10) + ' return (opts && opts.treatOfflineAsDown) ? "down" : "unknown";' + chr(10) + ' }', ' if (!h || h.ok !== true) return "down";'), ("去掉连续封顶(AUTO_START_MAX_STREAK 无限大)", BUS, "export const AUTO_START_MAX_STREAK = 3;", "export const AUTO_START_MAX_STREAK = 9999;"), ] def run_suite(): p = subprocess.run(["node", TEST], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300) out = p.stdout or "" m = re.search(SUMMARY, out) reds = [l.split(FAIL_MARK)[1].strip()[:44] for l in out.splitlines() if FAIL_MARK in l] if not m: why = (out.strip().splitlines() or [""])[-1] or (p.stderr or "").strip()[:80] return (-1, 1), ["(无汇总行,进程提前退出)" + why] return (int(m.group(1)), int(m.group(2))), reds def dirty(paths): try: r = subprocess.run(["git", "status", "--porcelain", "--"] + paths, cwd=ROOT, capture_output=True, text=True, timeout=20) return bool((r.stdout or "").strip()) except Exception: return True def main(): if dirty([os.path.relpath(BUS, ROOT), os.path.relpath(TR, ROOT)]): print("BLOCKED 目标文件有未提交改动,变异脚本不做叠写;先 commit 或 stash") print("VERDICT BLOCKED") return 2 bak = {f: open(f, "rb").read() for f in (BUS, TR)} def restore(): for f, b in bak.items(): open(f, "wb").write(b) for f, b in bak.items(): assert open(f, "rb").read() == b, "还原失败: " + f base, _ = run_suite() print("基线:通过 %d 失败 %d" % base) if base[1] != 0 or base[0] < 0: print("VERDICT FAIL 基线就没绿,变异检查无从谈起") return 1 vacuous = [] for name, path, old, new in MUTS: s = open(path, "rb").read().decode("utf-8") if s.count(old) != 1: vacuous.append(name + "(锚点命中 %d 次:实现已漂移,先更新本脚本)" % s.count(old)) print("%-56s 锚点失效" % name) continue open(path, "wb").write(s.replace(old, new, 1).encode("utf-8")) try: (np, nf), reds = run_suite() finally: restore() hit = nf > 0 print("%-56s 通过 %2d 失败 %2d %s" % (name, np, nf, "变红 OK" if hit else "仍全绿 = 空断言")) for r in reds[:3]: print(" 红:", r) if not hit: vacuous.append(name) again, _ = run_suite() print("还原后复核:通过 %d 失败 %d" % again) if vacuous or again[1] != 0: print("VERDICT FAIL vacuous=%d" % len(vacuous)) for v in vacuous: print(" -", v) return 1 print("VERDICT PASS mutations=%d" % len(MUTS)) return 0 if __name__ == "__main__": sys.exit(main())