#!/usr/bin/env python3 # -*- coding: utf-8 -*- """体积门禁强制路径(2026-10-25)。 要消除的盲区 ------------ `mem-numbers` / `mem-budget` 两根闸的**事实源都是 Keil 链接产物 `.map`** (gitignore,只有真做过 `/keil-build` 的机器上才有)。于是两处都是"没跑 = 通过": · CI 侧:runner 没有 Keil,workflow 只能把这根钩子**跳过**,历史上只打一条 `::warning::`(上一轮的处置)——"CI 全绿"仍不等于"体积守住了"; · 本地侧:`ram_baseline.py --gate` 在无 `.map` 时自适应 SKIP 并返回 0,所以 "改了代码但根本没编译"这条最常见的漏测路径,本地也是绿的。 本脚本把"没跑"变成"跑不过":给体积数字配一条**可提交、CI 可复核**的同码凭证 (`scripts/size_attestation.json`),而不是要求 runner 装 Keil。凭证只能由 "新鲜 `.map` + 未超线"的测量写出,因此"代码变了但没重测"会在同码性上暴露。 三个入口(退出码统一:0=过 / 1=门禁失败 / 2=环境与解析错误) --stamp 测量后盖章(已挂在 `ram_baseline.py --update` 里,不新增人工步骤)。 三道前置任一不过就**拒绝盖章**:1) `.map` 存在且解析得出执行区与 Grand Totals;2) `.map` 不早于指纹范围内任何源码的 mtime(防"改完代码 拿旧构建盖章");3) 受管区占比 <= `ram_baseline.THRESHOLD_PCT`(与 mem-budget 同一根线、同一个常数,不另立第二套口径)。 --local 开发者自查 / ci_local 的体积门禁步:工作树指纹必须等于凭证指纹。 --ci runner 上跑(无 Keil、无 `.map`):A. 同码性;B. 凭证里记录的受管区 占比 <=92%(数字来自一次真实链接);C. 与 `ram_baseline.tsv` 末行同源=OK, 不同源=WARN(多半只 --stamp 没 --update,不拦)。 --show 打印当前指纹与凭证状态(排障用)。 口径与边界(**正文只写这一处**,其它文档引用本节,勿另抄一份) -------------------------------------------------------------- 1. 本门禁**能**判:① 这份代码有没有对应一次真实 Keil 链接(同码性); ② 那次测量的 RAM 受管区是否 <=92%(用已提交的测量数字守红线)。 2. 本门禁**不能**判: · CI 不重算绝对体积——runner 无 Keil/无 `.map`,绝对数字的可信度来自本地那次 `/keil-build`;本脚本把盖戳卡在"新鲜 .map + 未超线"之后,凭证不是随手可写的。 · **编译器选项不在指纹范围内**:`MDK-ARM/STM32F407-Demo.uvprojx` 每次构建都被 UV4 改写且按约定不入库(keil-build skill),把它放进指纹会让门禁在"刚刚构建完" 的那一刻自己变红;代价是改优化等级/预处理宏不触发本门禁,这类改动必须人工 重测。scatter `.sct` 与启动文件 `.s` 不受抖动影响,**在**指纹范围内。 · 防的是**遗忘与漂移**,不防**蓄意伪造**:伪造需同时改 size_attestation.json 与 ram_baseline.tsv 两个已跟踪文件,而 .map 不在库里、评审可见。 3. 强制落点:**主干 push 为红**(唯一能保证"这份代码就是要发出去的那份"的位置); 其余事件由 workflow 加 `--warn-only` 运行——分支上的代码尚未定型,在那里拦红 只会制造噪音,并诱发多条道同时抢写 scripts/ 台账。 指纹范围(= 会影响固件体积的全部受管源码) App/ Drivers/ Src/ Inc/ Lib/ Middlewares/ test/board/ 以及 MDK-ARM 下的 .sct/.s 身份取 **git 索引 blob**(`git ls-files -s` 的 path+blob 逐行聚合哈希),不是工作树 原始字节——本仓 core.autocrlf=true 而 .gitattributes 写死 eol=lf,实测主树 1046/1388 个受管源码落成 CRLF、新建工作树同一提交落成 LF,而索引 blob 完全一致;按原始字节算 会让同一份代码在不同 checkout 得到不同指纹(主树盖的戳在别人工作树与 runner 上误红) → 索引 blob 才是跨 checkout 稳定的身份,也是 AGENTS「不依赖 core.autocrlf」的机器化。 推论:**未 staged 的改动不在索引里**,凭证覆盖不到 → --stamp 拒绝在有脏时盖章, --local/--ci 也把它单独报出来(已 git add 的不算脏:索引已含其内容,正是要测的那份)。 用法(仓库根): python scripts/check_size_gate.py --stamp python scripts/check_size_gate.py --local python scripts/check_size_gate.py --ci [--warn-only] python scripts/check_size_gate.py --show 设计依据:本 docstring(体积门禁口径的唯一正文)、docs/CI/README.md。 创建日期:2026-10-25 """ import argparse import hashlib import json import os import pathlib import subprocess import sys from datetime import datetime, timezone sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) # 复用同一套 .map 解析与阈值常数:体积口径只许有一处定义(docs/文档规范.md) import ram_baseline # noqa: E402 import ram_report # noqa: E402 REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent ATTESTATION = REPO_ROOT / "scripts" / "size_attestation.json" # v1 = 工作树原始字节指纹(跨 checkout 不稳定,见 index_snapshot 的实测说明)→ 作废; # v2 = git 索引 blob 指纹。schema 不符即拒绝复用,逼一次重新盖戳,不静默误判。 SCHEMA = 2 SIZE_SCOPE_DIRS = ("App/", "Drivers/", "Src/", "Inc/", "Lib/", "Middlewares/", "test/board/") SIZE_SCOPE_ROOT = "MDK-ARM/" SIZE_SCOPE_EXTS = {".sct", ".s"} def in_scope(rel): """rel 为 git 风格 posix 路径。""" if rel.startswith(SIZE_SCOPE_DIRS): return True if rel.startswith(SIZE_SCOPE_ROOT): return os.path.splitext(rel)[1].lower() in SIZE_SCOPE_EXTS return False def _git(args): """跑 git 并返回 stdout 文本;git 不可用/非仓库 → 抛 RuntimeError(调用方按环境错误)。""" try: out = subprocess.run(["git"] + args, cwd=str(REPO_ROOT), capture_output=True, check=True) except subprocess.CalledProcessError as exc: raise RuntimeError("git %s 失败:%s" % (" ".join(args), exc)) from exc except (OSError, subprocess.SubprocessError) as exc: raise RuntimeError("无法调用 git:%s" % exc) from exc return out.stdout def index_snapshot(): """受管且**在指纹范围内**的 (路径, 索引 blob id) 有序表。 ⚠ 为什么取**索引 blob**而不是工作树原始字节(2026-10-25 实测发现,别改回去): 本仓 core.autocrlf=true,而 .gitattributes 写死 eol=lf —— 主工作树的受管源码 里有 **1046/1388 个文件落成 CRLF**,新建工作树同一提交落成 LF,两者**索引 blob 完全相同**(实测 dbg_cfg.h:1247 B/LF vs 1293 B/CRLF,blob 都是 c39395f7)。 拿原始字节算指纹 = 同一份代码在不同 checkout 得到不同指纹:主树盖的戳在道C 新工作树里判"不同码",runner 上同样会误红——**门禁自己造 false positive**。 索引 blob 是 git 归一化后的身份,跨 checkout 稳定,也正是 AGENTS「行尾由 .gitattributes 写死、不依赖 core.autocrlf」的机器化说法。 """ raw = _git(["ls-files", "-s", "-z"]) rows = [] for item in raw.split(b"\0"): if not item: continue meta, _, path = item.partition(b"\t") # " " parts = meta.decode("ascii", "replace").split(" ") if len(parts) < 2: continue rel = path.decode("utf-8", "surrogateescape").replace(os.sep, "/") if in_scope(rel): rows.append((rel, parts[1])) return sorted(rows) def tree_fingerprint(): """(指纹, 文件数)。指纹 = sha256(每行 "path\\tblob" 拼接),取自 git 索引。""" rows = index_snapshot() h = hashlib.sha256() for rel, blob in rows: h.update(rel.encode("utf-8", "surrogateescape")) h.update(b"\t") h.update(blob.encode("ascii", "replace")) h.update(b"\n") return h.hexdigest(), len(rows) def unmeasured_dirt(limit=8): """范围内**未 staged** 的改动(含未跟踪文件)——它们不在索引里,指纹覆盖不到。 返回 (条数, 样例列表)。判据用 porcelain v1 的第二列(工作树列):非空格且非 未跟踪标记即"改了没 staged";'??' = 未跟踪新文件。⚠ 第一列(索引列)有改动是 **允许**的——那正是"已 git add、即将提交"的那份内容,索引已经把它算进指纹。 """ raw = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) dirty = [] for rec in raw.split(b"\0"): if len(rec) < 4: continue xy = rec[:2].decode("ascii", "replace") rel = rec[3:].decode("utf-8", "surrogateescape").replace(os.sep, "/") if not in_scope(rel): continue if xy == "??": dirty.append(rel) elif xy[1] != " ": dirty.append(rel) return len(dirty), dirty[:limit] def newest_source_mtime(): """范围内**工作树文件**的最新 mtime(只用于 .map 新鲜度守卫,不进指纹)。 注意这里必须看工作树:守卫要抓的是"盖戳前又改了文件却没重编",而那正是索引 之外(未 staged)或已 staged 但磁盘更新的那份内容。文件已删除则跳过(增删本身 由指纹变化表达)。 """ newest = 0.0 for rel, _blob in index_snapshot(): try: newest = max(newest, (REPO_ROOT / rel).stat().st_mtime) except OSError: continue return newest def measure(map_path): """.map -> 测量数字;解析不出返回 None(调用方按环境错误处理)。""" info = ram_baseline.collect(map_path) if not info["regions"]: return None gt = ram_report.parse_map(str(map_path))["grand_totals"] if not gt: return None return { "info": info, "sizes": {"code": gt["code"], "ro_data": gt["ro_data"], "rw_data": gt["rw_data"], "zi_data": gt["zi_data"]}, "regions": {name: {"size": r["size"], "max": r["max"]} for name, r in info["regions"].items()}, "pct": {name: round(ram_baseline.region_pct(r), 2) for name, r in info["regions"].items()}, } def budget_offenders(m): """受管区超阈值条目(判据与 ram_baseline 的 --gate 完全一致)。""" bad = [] for name in ram_baseline.GATED_REGIONS: r = m["info"]["regions"].get(name) if not r: bad.append("缺执行区 %s(scatter 变更?)" % name) continue u = ram_baseline.region_pct(r) if u > ram_baseline.THRESHOLD_PCT: bad.append("%s 占用 %.1f%% > %.0f%%(%s/%s B)" % ( name, u, ram_baseline.THRESHOLD_PCT, format(r["size"], ","), format(r["max"], ","))) return bad def read_attestation(): if not ATTESTATION.exists(): return None, "凭证不存在(%s)" % ATTESTATION.relative_to(REPO_ROOT) try: doc = json.loads(ATTESTATION.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: return None, "凭证解析失败:%s" % exc if doc.get("schema") != SCHEMA: return None, "凭证 schema=%r 与本脚本 %d 不符" % (doc.get("schema"), SCHEMA) if not doc.get("fingerprint"): return None, "凭证缺 fingerprint 字段" return doc, None def write_attestation(doc): ATTESTATION.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n") def _git_head(): try: out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(REPO_ROOT), capture_output=True, timeout=20) return out.stdout.decode("ascii", "replace").strip() or None except Exception: # noqa: BLE001 return None def cmd_stamp(args): """用新鲜 .map 盖同码戳;三道前置任一不过即拒绝(见模块 docstring)。""" map_path = pathlib.Path(args.map) if not map_path.exists(): print("[FAIL] 没有 .map(%s)——先 /keil-build 再盖戳" % map_path, file=sys.stderr) return 1 m = measure(map_path) if m is None: print("[FAIL] .map 解析不出执行区/Grand Totals:%s" % map_path, file=sys.stderr) return 2 map_mt = map_path.stat().st_mtime src_mt = newest_source_mtime() if src_mt > map_mt: print("[FAIL] .map 早于最新受管源码——这份构建不含最新改动,重新 /keil-build", file=sys.stderr) print(" .map mtime=%d 最新源码 mtime=%d" % (int(map_mt), int(src_mt)), file=sys.stderr) return 1 bad = budget_offenders(m) if bad: print("[FAIL] 体积已超阈值,拒绝盖戳(先削峰,勿抬阈值):", file=sys.stderr) for b in bad: print(" - " + b, file=sys.stderr) return 1 # 第四道前置:范围内**有未 staged 的改动就拒绝盖戳**——索引(= 即将提交的那份) # 里没有它们,凭证会"声称覆盖"一份并不存在的代码。已 git add 的改动**不算脏** # (索引已含其内容,正是凭证该描述的东西)。 dirt, samples = unmeasured_dirt() if dirt: print("[FAIL] 指纹范围内有 %d 处未 staged 的改动/未跟踪文件,拒绝盖戳:" % dirt, file=sys.stderr) for s in samples: print(" - " + s, file=sys.stderr) print(" 原因:凭证按 git 索引内容生成,未 staged 的部分不在索引里、覆盖不到。", file=sys.stderr) print(" 修复:先 git add <这些文件>(凭证描述的就是你正要提交的那份),再盖戳;", file=sys.stderr) print(" 或把它们还原。/keil-build 与盖戳都应在同一份内容上做。") return 1 fp, n = tree_fingerprint() write_attestation({ "schema": SCHEMA, "fingerprint": fp, "fingerprint_source": "git-index", "fingerprint_files": n, "worktree_dirt_at_stamp": dirt, "sizes": m["sizes"], "regions": m["regions"], "pct": m["pct"], "gated_regions": list(ram_baseline.GATED_REGIONS), "threshold_pct": ram_baseline.THRESHOLD_PCT, "map": str(map_path.relative_to(REPO_ROOT)).replace(os.sep, "/"), "map_mtime": int(map_mt), "built_at": datetime.now(timezone.utc).astimezone().isoformat( timespec="seconds"), "git_head": _git_head(), "written_by": "scripts/check_size_gate.py --stamp", "scope_note": "同码凭证:指纹覆盖固件体积相关源码,口径见本脚本 docstring", }) ir = m["regions"].get("RW_IRAM1", {}).get("size", 0) cc = m["regions"].get("RW_CCM", {}).get("size", 0) print("[OK] 体积同码凭证已写出:%s(索引指纹 %s…,%d 个文件)" % ( ATTESTATION.relative_to(REPO_ROOT), fp[:12], n)) print(" Code=%s B / RW_IRAM1=%s B / RW_CCM=%s B" % ( format(m["sizes"]["code"], ","), format(ir, ","), format(cc, ","))) return 0 def _report(args, ci_mode, lines): """失败输出:CI 事件加 GHA 标注;--warn-only 降级为 0(非主干不拦)。""" if ci_mode: tag = "::warning::" if args.warn_only else "::error::" print(tag + " 体积门禁未通过(强制路径)", file=sys.stderr) for ln in lines: print(ln, file=sys.stderr) if args.warn_only: print("[WARN] 已按 --warn-only 降级为告警(非主干事件不拦,见 workflow)") return 0 return 1 def _cmd_parity(args, ci_mode): doc, err = read_attestation() if doc is None: return _report(args, ci_mode, [ "体积同码凭证不可用:" + err, " 修复:本地 /keil-build 之后跑 python scripts/ram_baseline.py --update", " (或单独跑 python scripts/check_size_gate.py --stamp)", ]) fp, n = tree_fingerprint() problems = [] dirt, samples = unmeasured_dirt() if dirt: problems += [ "指纹范围内有 %d 处**未 staged** 的改动/未跟踪文件,凭证覆盖不到它们" % dirt, " " + "、".join(samples) + ("…" if dirt > len(samples) else ""), " 含义:这些内容既没进索引(凭证按索引生成),也就没被那次测量算进去", " 修复:git add 之后 /keil-build,再跑 python scripts/ram_baseline.py --update", ] if fp != doc["fingerprint"]: problems += [ "本次代码(指纹 %s…,%d 文件)与体积测量(指纹 %s…,%s)不同码" % ( fp[:12], n, str(doc["fingerprint"])[:12], doc.get("built_at", "?")), " 含义:这份源码没有被真实链接测量过(或测量完又改了代码/合入了别的改动)", " 修复:/keil-build 0/0 之后跑 python scripts/ram_baseline.py --update 再提交", " (指纹取 git 索引 blob:同一份内容在任何工作树/runner 上算法一致," "不受 CRLF/LF 检出差异影响)", ] if ci_mode: thr = doc.get("threshold_pct", ram_baseline.THRESHOLD_PCT) bad = [] for name in doc.get("gated_regions") or list(ram_baseline.GATED_REGIONS): pct = (doc.get("pct") or {}).get(name) if pct is None: bad.append("凭证缺 %s 占比" % name) elif float(pct) > float(thr): size = int(((doc.get("regions") or {}).get(name) or {}).get("size", 0)) bad.append("%s 占用 %.1f%% > %.0f%%(测量 %s B)" % ( name, float(pct), float(thr), format(size, ","))) if bad: problems.append("CI 按已提交测量数字复核 92%% 红线:") problems += [" - " + b for b in bad] _tsv_consistency(doc) if problems: return _report(args, ci_mode, problems) print("[OK] 体积门禁通过(%s):同码 ✓ 受管区 <=%.0f%% ✓(测量于 %s)" % ( "CI 复核:无 Keil/无 .map" if ci_mode else "本地自查", float(doc.get("threshold_pct", ram_baseline.THRESHOLD_PCT)), doc.get("built_at", "?"))) print(" 边界:CI 不重算绝对数字、编译器选项(uvprojx)不在指纹内——" "正文见 scripts/check_size_gate.py docstring") return 0 def _tsv_consistency(doc): """凭证数字与趋势台账末行互证(不同源只 WARN:只 --stamp 没 --update 是合法态)。""" tsv = REPO_ROOT / "scripts" / "ram_baseline.tsv" if not tsv.exists(): print("[WARN] ram_baseline.tsv 不存在——凭证无法与趋势台账互证") return try: rows = ram_baseline.read_tsv_rows() except Exception as exc: # noqa: BLE001 print("[WARN] ram_baseline.tsv 解析失败(%s)——跳过互证" % exc) return if not rows: return last = rows[-1] mine = int((doc.get("sizes") or {}).get("code", -1)) theirs = int(last.get("code", -2)) if mine == theirs: print("[OK] 凭证与 tsv 末行同源:Code=%s B(%s)" % (format(mine, ","), last.get("date", "?"))) else: print("[WARN] 凭证 Code=%s 与 tsv 末行 Code=%s 不同源——多半只 --stamp 没跑 " "--update(不拦,台账随后补)" % (format(mine, ","), format(theirs, ","))) def cmd_show(_args): fp, n = tree_fingerprint() dirt, samples = unmeasured_dirt() print("当前受管源码索引指纹:%s(%d 文件;未 staged %d 处)" % (fp, n, dirt)) for s in samples: print(" dirty: " + s) doc, err = read_attestation() if doc is None: print("凭证:%s" % err) return 1 print("凭证指纹:%s%s" % (doc["fingerprint"], " <- 同码" if doc["fingerprint"] == fp else " <- 不同码")) print("凭证测量:%s / map=%s / head=%s" % ( doc.get("built_at", "?"), doc.get("map", "?"), str(doc.get("git_head", "?"))[:8])) print("数字:%s" % (doc.get("sizes"),)) print("占比:%s(受管区 %s,阈值 %s%%)" % ( doc.get("pct"), doc.get("gated_regions"), doc.get("threshold_pct"))) return 0 def main(argv=None): for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") except Exception: # noqa: BLE001 pass ap = argparse.ArgumentParser( description="体积门禁强制路径(同码凭证 + CI 红线复核)") g = ap.add_mutually_exclusive_group() g.add_argument("--stamp", action="store_true", help="用新鲜 .map 盖同码戳(/keil-build 后)") g.add_argument("--local", action="store_true", help="本地/ci_local 自查:工作树须与测量同码") # ⚠ help 字符串里 % 必须写成 %%(argparse 会对 help 做 %-插值;裸 % + 中文 # 如"92% 红"在**无参/非法参的 print_help 路径**上才炸:ValueError # unsupported format character(2026-10-25 道F task-22 顺手修,直调崩溃) g.add_argument("--ci", action="store_true", help="CI 模式:同码性 + 已提交数字的 92%% 红线") g.add_argument("--show", action="store_true", help="打印当前指纹与凭证状态") ap.add_argument("--map", default=str(ram_baseline.MAP_DEFAULT), help=".map 路径(--stamp 用)") ap.add_argument("--warn-only", action="store_true", help="失败降级为告警并退出 0(workflow 用于非主干事件)") args = ap.parse_args(argv) if args.stamp: return cmd_stamp(args) if args.local: return _cmd_parity(args, ci_mode=False) if args.ci: return _cmd_parity(args, ci_mode=True) if args.show: return cmd_show(args) ap.print_help() return 3 if __name__ == "__main__": sys.exit(main())