3965eb3f28
把 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,**我量出来了却仍然按了提交**。 记在这里是给下一篇门禁详解留素材:这条门禁拦下过的人里也包括写门禁的人自己,而「拦下过 什么真事」那一栏不该只挑别人的事写。
293 lines
12 KiB
Python
293 lines
12 KiB
Python
# -*- 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())
|