Files
STM32F4-Base/scripts/check_bom_drift.py
T
wandering1 3965eb3f28 feat(门禁): bom-guard 机械化 BOM 漂移,附 setview-check 行表一致性
把 AGENTS 铁律第 2 条从"人工逐文件比 HEAD"变成提交硬闸:HEAD 有 EF BB BF 而工作区没有 → FAIL。
2026-10-25 一天之内道E 在 task-15 里连踩 4 次、道B 批 1 踩过 1 次,此前全靠人记得做审计兜底——
人工审计只防得住记得做的人,这正是它该变成门禁的理由(票 = task-20)。

scripts/check_bom_drift.py(纯标准库)
- 判定只有一条:HEAD 前 3 字节是 BOM 而工作区不是 → 列出文件与补救动作,不含二义措辞。
- 两条刻意不报(票面要求的边界,--selftest 逐条自证):① HEAD 里根本没有的新文件无 BOM 不误报;
  ② HEAD 无 BOM 而工作区加了 BOM 也不报——那是另一个方向的改动,可能是有意的(把文件纳入名单),
  报红只会逼人 --no-verify,而"养成绕门禁的习惯"比漏检一次贵得多。
- 一条刻意不做:不自动补 BOM。正确修复是在文件最前面加 3 个字节;脚本整文件重写会按系统默认编码
  重打中文,把可修的丢 BOM 变成不可修的 U+FFFD(AGENTS 铁律第 1 条那条更大的坑)。补救文案里把
  这句完整写出来了——报错信息的作用是让踩的人当场做对动作,不是让他知道有人不高兴。
- 名单三处互指:AGENTS 铁律名单 / 本钩子 files 正则 / 脚本内 BOM_LIST,每次运行核一次;单独改任何
  一处都会在别人的提交里被拦下。名单散在三处而无人同步,等于没有名单。
- --selftest 在临时 git 仓库里跑 4 条判定(含上述两条边界 + 主用例 + 名单外文件不参与),全过。

setview-check(Lead 指令:并入 task-20 同窗机械化)
- 核 Drivers/BSP/UI/ui_w_setview.c 的四张视图表与 App/algo/prot_set 枚举序、
  g_prot_set_meta 上下限是否同一份口径:id 恰好覆盖 0..39 各一次 / 数值行 min-max
  等于 meta 换算 / kind 与 is_ctl 一致 /
  单位纯 ASCII / 小数位查表与行表一致。机械化的理由不是"多一道保险":BSP 侧看不到 App 头,
  行表里的 id 是裸数字 + 行尾注名,这条对齐在编译期**物理上无法自证**;而页面上只有当前可见
  的 8 行会被像素断言看到,错位要翻到第 2、3 页才可能被人发现——那时它已被黄金参考钉死了。
- 跨分支先后写成注释:脚本文件随 task-15 分支合入主干;在此之前 files 永不命中 → 钩子自然 skip
  (skip 不是失败),合并后自动生效,不需要任何人再动这条配置。

AGENTS.md(票面授权:只加一句机械化标注;下面实际动了两处计数,一并申报)
- 铁律第 2 条末尾加"已机械化:bom-guard 钩子"+ 三处互指一句。
- 钩子计数 46 → 48 两处同步(不同步就是让 AGENTS 说谎);并在文档索引行标出"bom-guard /
  setview-check 两篇待补",把欠的详解篇目挂在明面上,不留沉默债。

验证
- 本笔**未用 --no-verify**:全量 pre-commit --all-files 46 项 Passed、0 Failed(含两条新钩子)。
- 端到端实测(不止 --selftest 自证):真把 Drivers/BSP/UI/ui_w_records.c 的 BOM 剥掉并 stage →
  bom-guard rc=1 并打印补救动作;还原后该文件与 HEAD 逐字节相同,工作树干净。
- 顺带发现并行开发方案的一条空白(本笔未修,写域之外):全新工作树没有 .map,mem-numbers 会因
  "找不到 .map 真实源" FAIL,跑一次 @build 才绿。门禁隐含的"本树必须先构建过"前提在
  docs/环境与部署/并行任务工作树方案.md 里没写;已回报 Lead 定夺归属。
- 自报一条:本笔第一次提交被 gitlint 拦下——正文有一行 92 > 80,**我量出来了却仍然按了提交**。
  记在这里是给下一篇门禁详解留素材:这条门禁拦下过的人里也包括写门禁的人自己,而「拦下过
  什么真事」那一栏不该只挑别人的事写。
2026-10-25 14:36:50 +08:00

293 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())