# -*- coding: utf-8 -*- """ 模块名称:BOM 漂移检查(提交门禁 bom-guard 的实现) 模块功能:对给定文件集合(默认 = pre-commit 传进来的本次所改文件)逐个比较 HEAD 版本与工作区 版本的前 3 字节:HEAD 有 UTF-8 BOM(EF BB BF)而工作区没有 → FAIL 并列出补救动作。 为什么要这条:AGENTS 铁律第 2 条记载的实测坑——编辑工具会**静默剥掉**文件原有 BOM,ARMCC 于是按 CP936 解码带中文的源文件,报出 #870-D / #8 / #274 一类**假语法错**(错的位置 和内容都不指向真因);MSVC(模拟器侧)与任何"只跑编译"的门禁都不覆盖这一步,历史 "0 警告"不代表安全。2026-10-25 一天之内道E 一个任务踩了 4 次、道B 批 1 踩过 1 次, 此前全靠人工逐文件比 HEAD 兜底——人工审计只有"记得做的人"才会做,本脚本把它变成 提交时的硬闸。 刻意不做(写清楚比补上更容易维持): ① 不报"HEAD 无 BOM、工作区加了 BOM":那是另一个方向的改动,可能是有意的(把文件 纳入 BOM 名单),报红只会逼人 --no-verify,而绕过门禁的习惯比漏检更贵; ② 不自动补 BOM:正确修复是"在最前面加 3 个字节",用脚本整文件重写反而可能把中文 按错误编码重打一遍(本仓记录的更大的坑),所以只报文件与动作; ③ 不报已删除的文件:删除不是 BOM 漂移,且删除自有 review 兜。 名单互指(三处必须一致,本脚本每次运行都核一次,不一致即 FAIL): AGENTS.md 铁律第 2 条列出的带 BOM 文件名单、.pre-commit-config.yaml 里 bom-guard 钩子的 files 正则、下面的 BOM_LIST。任何一处单独改动都会在别人的提交里被拦下—— 这正是目的:名单散在三处而无人同步,等于没有名单。 用法: .venv/Scripts/python.exe scripts/check_bom_drift.py [文件名...] [--all] [--selftest] 不带文件名也不带 --all 时(pre-commit 的常规形态)只核传进来的文件;--all 核全仓 跟踪文件(人工审计/排查用,比 HEAD 全量比一遍慢,但不依赖"改没改过");--selftest 在临时 git 仓库里自证四条判定(含票面要求的两条边界)。 退出码: 0 = 通过;1 = 有 BOM 漂移或名单不一致;2 = 用法或环境错误(不在 git 仓库内等) 创建日期:2026-10-25(道E task-20) """ import argparse import os import subprocess import sys import tempfile BOM = bytes((0xEF, 0xBB, 0xBF)) # 名单项:(路径, 匹配方式),prefix = 目录内全部文件,eq = 恰好这一个文件 BOM_LIST = [ ("Drivers/BSP/UI/", "prefix"), ("App/task/ui_task.c", "eq"), ("App/cfg/cfg_file.c", "eq"), ("Lib/lv_conf.h", "eq"), ] AGENTS_FILE = "AGENTS.md" CONFIG_FILE = ".pre-commit-config.yaml" # Windows 控制台默认按 CP936 写 stdout:本脚本要打印中文,先把输出锁到 UTF-8。 # 不锁的后果不是难看,是**崩在半路**——门禁脚本崩在半路等于一条都没检查(同一课已在 # tools/setview_check 脚本上记过一次,这里不再重犯)。 try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass def in_list(path): """path 是否在带 BOM 名单内(git 与控制台都用正斜杠,不做转换)。""" for name, kind in BOM_LIST: if kind == "prefix": if path.startswith(name): return True elif path == name: return True return False def git_run(args): try: r = subprocess.run(["git"] + args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) except OSError: return None if r.returncode != 0: return None return r.stdout def head_prefix(path): """HEAD 版本前 3 字节;不在 HEAD(新增文件)返回 None → 不误报。""" data = git_run(["show", "HEAD:" + path]) if data is None: return None return data[:3] def work_prefix(path): """工作区版本前 3 字节;读不到(已删除等)返回 None → 不报。""" try: with open(path, "rb") as f: return f.read(3) except OSError: return None def check_paths(paths): """返回 (违规列表, 核过条数);违规项 = 路径。""" bad = [] n = 0 for p in paths: if not in_list(p): continue hp = head_prefix(p) if hp is None: continue # 新增文件:HEAD 无 BOM 可谈,不误报(票面边界①) n += 1 if hp != BOM: continue # HEAD 本来就没 BOM:不报(票面边界②) wp = work_prefix(p) if wp is None: continue # 已删除:不是本门禁的事 if wp != BOM: bad.append(p) return bad, n def tracked_files(): data = git_run(["ls-files", "-z"]) if data is None: return [] return [x for x in data.decode("utf-8", "replace").split(chr(0)) if x] def list_crosscheck(): """核 AGENTS 名单 / 钩子 files 正则 / BOM_LIST 三处是否互指一致。""" problems = [] names = [n for n, _ in BOM_LIST] try: agents = open(AGENTS_FILE, encoding="utf-8").read() except OSError: agents = None problems.append("读不到 " + AGENTS_FILE + "(不在仓库根执行?)") try: cfg = open(CONFIG_FILE, encoding="utf-8").read() except OSError: cfg = None problems.append("读不到 " + CONFIG_FILE) if agents is not None: seg = "" for line in agents.split(chr(10)): if "BOM" in line: seg += line for n in names: key = n.rstrip("/") if key not in seg: problems.append("AGENTS.md 的 BOM 条目里没有列到 " + n) if cfg is not None: seg = "" hit = False for line in cfg.split(chr(10)): if "id: bom-guard" in line: hit = True elif hit and "files:" in line: seg = line break if not seg: problems.append("在 " + CONFIG_FILE + " 里找不到 bom-guard 钩子的 files 行") else: # 正则里的反斜杠与锚点是**写法**,不是名单内容:比之前先脱掉,否则 # App/task/ui_task\.c$ 会被判成"没覆盖 App/task/ui_task.c"。这条检查要核的是 # 三处的意图是否一致,不是字节是否相同 flat = seg.replace(chr(92), "").replace("^", "").replace("$", "") for n in names: key = n.rstrip("/") if key not in flat: problems.append("bom-guard 的 files 正则没覆盖 " + n) return problems def report(bad): print("[FAIL] 以下文件的 BOM 被剥掉了(HEAD 有 EF BB BF,工作区没有):") for p in bad: print(" " + p) print(" 补救:只把 3 个字节 EF BB BF 补回文件最前面;**不要**整文件重写——") print(" 用 PowerShell/Set-Content 之类整文件重写会按系统默认编码重打中文,") print(" 那会把可修的丢 BOM 变成不可修的 U+FFFD(AGENTS 铁律第 1/2 条)。") print(" 为什么必须修:ARMCC 无 BOM 时按 CP936 解码带中文的源文件,报的是 #870-D/#8/#274") print(" 这类**假语法错**,模拟器(MSVC)0 警告照不出来,只有重编该文件才暴露。") def selftest(): """在临时 git 仓库里跑四条判定(含票面要求的两条边界),任一不符返回 1。""" ok = True tmp = tempfile.mkdtemp(prefix="bomdrift_") old = os.getcwd() try: os.chdir(tmp) for a in (["init", "-q"], ["config", "user.email", "t@t"], ["config", "user.name", "t"]): if git_run(a) is None: print("[SELFTEST FATAL] 临时仓库 git 初始化失败") return 1 os.makedirs("Drivers/BSP/UI") os.makedirs("App/task") os.makedirs("App/cfg") os.makedirs("Lib") bomf = "Drivers/BSP/UI/t.c" with open(bomf, "wb") as f: f.write(BOM + b"/* x */" + chr(0x4e2d).encode("utf-8")) with open("App/task/ui_task.c", "wb") as f: f.write(b"/* plain, no BOM in HEAD */") git_run(["add", "-A"]) if git_run(["commit", "-q", "-m", "seed"]) is None: print("[SELFTEST FATAL] 临时仓库提交失败") return 1 bad, n = check_paths([bomf]) if n != 1 or bad: print("[SELFTEST FAIL] 未改动的带 BOM 文件被判成违规(应 0 违规、核过 1 个)") ok = False with open(bomf, "rb") as f: body = f.read() with open(bomf, "wb") as f: f.write(body[3:]) bad, n = check_paths([bomf]) if bad != [bomf]: print("[SELFTEST FAIL] 剥掉 BOM 后没报(主用例失守 = 门禁等于没有)") ok = False with open(bomf, "wb") as f: f.write(body) # 边界①:HEAD 里根本没有的新文件(哪怕在名单目录内)不得误报 with open("Drivers/BSP/UI/new.c", "wb") as f: f.write(b"/* brand new, no BOM */") bad, n = check_paths(["Drivers/BSP/UI/new.c"]) if bad or n != 0: print("[SELFTEST FAIL] 新增无 BOM 文件被误报") ok = False os.remove("Drivers/BSP/UI/new.c") # 边界②:HEAD 无 BOM、工作区加了 BOM 不得报(另一个方向的改动,可能有意) with open("App/task/ui_task.c", "wb") as f: f.write(BOM + b"/* now with BOM */") bad, n = check_paths(["App/task/ui_task.c"]) if bad: print("[SELFTEST FAIL] HEAD 无 BOM 而工作区加了 BOM 被误报") ok = False # 名单外文件即使 BOM 丢了也不报(门禁不越权管名单外) with open("Lib/lv_conf.h", "wb") as f: f.write(b"x") bad, n = check_paths(["App/cfg/cfg_file.c"]) if bad or n != 0: print("[SELFTEST FAIL] 名单内但不在 HEAD 的文件被误报") ok = False bad, n = check_paths(["README.md"]) if n != 0: print("[SELFTEST FAIL] 名单外文件参与了检查") ok = False finally: os.chdir(old) print("[SELFTEST] " + ("4 条判定全过(含两条票面要求的边界)" if ok else "有判定未过")) return 0 if ok else 1 def main(): ap = argparse.ArgumentParser(description="BOM 漂移检查(bom-guard 钩子实现)") ap.add_argument("files", nargs="*", help="待核文件(仓库根相对路径)") ap.add_argument("--all", action="store_true", help="核 git 跟踪的全部文件") ap.add_argument("--selftest", action="store_true", help="临时仓库自证判定") args = ap.parse_args() if args.selftest: return selftest() if git_run(["rev-parse", "--git-dir"]) is None: print("[FATAL] 不在 git 仓库里(或 git 不可用),本检查无法比对 HEAD") return 2 paths = tracked_files() if args.all else args.files if not paths and not args.all: return 0 bad, n = check_paths(paths) problems = list_crosscheck() if bad: report(bad) if problems: print("[FAIL] BOM 名单三处互指不一致(名单散着改 = 名单失效):") for m in problems: print(" " + m) print(" 要一起改的三处:AGENTS.md 铁律第 2 条、.pre-commit-config.yaml 的 bom-guard") print(" files 正则、scripts/check_bom_drift.py 的 BOM_LIST。") if not bad and not problems: print("BOM 漂移检查:核过 %d 个名单内文件,无漂移;名单三处互指一致" % n) return 1 if (bad or problems) else 0 if __name__ == "__main__": sys.exit(main())