Files
STM32F4-Base/scripts/check_size_gate.py
T
wandering1 94cce8b615 docs+scripts: 删除测试与架构专项文档并把引用改成就地口径
这是并行的另一路在飞的改动(本会话按用户「全部提交」指示代为落盘):
移除 docs/任务规划/测试与架构专项.md,并把指向它的引用改成就地口径
(app_assert.h / net_socket.c / ch395f_test.h 头注释改指本文件注释或
对应文档,check_size_gate.py 与 ram_baseline.py 的出处改指 CI 文档,
RAM预算台账、暂缓-RS485、采集与算法层设计说明、cubemx-survival、
id-check、两份 README 同步改引)。

如要拆回:git reset --soft HEAD~1 即可把这一笔退回工作区。
合树后 @build 0 错 0 警(Code=236456 RO=28124 RW=1876 ZI=137084)。
2026-10-28 22:00:34 +08:00

472 lines
22 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""体积门禁强制路径(2026-10-25)。
要消除的盲区
------------
`mem-numbers` / `mem-budget` 两根闸的**事实源都是 Keil 链接产物 `.map`**
(gitignore,只有真做过 `@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`,绝对数字的可信度来自本地那次
`@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") # "<mode> <sha> <stage>"
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)——先 @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 早于最新受管源码——这份构建不含最新改动,重新 @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(" 或把它们还原。@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,
" 修复:本地 @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 之后 @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", "?")),
" 含义:这份源码没有被真实链接测量过(或测量完又改了代码/合入了别的改动)",
" 修复:@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 盖同码戳(@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())