Files
STM32F4-Base/scripts/check_size_gate.py
T
wandering1 e25e51c378 ci(A-5): 体积门禁补强制路径——同码凭证 + CI 无需 Keil 复核 92% 红线
背景:A-5 残余(docs/任务规划/测试与架构专项.md)说得很直白——mem-numbers 在
CI 被跳过(runner 无 Keil/无 .map),上一轮只把"跳过"声明成一条 ::warning::,
"盲区本身未消除":看到 CI 全绿仍会以为体积也守住了。

复核时本地侧同样漏:ram_baseline.py --gate 在无 .map 时自适应 SKIP 且返回 0;
mem-budget 甚至没进 CI 的 SKIP 名单——它在 runner 上以"Passed"的面貌出现,实际
什么都没检查。两处共同点是**"没跑 = 通过"**。

做法:给体积数字配一条可提交、CI 可复核的**同码凭证**,而不是要求 runner 装 Keil。
- 新增 scripts/check_size_gate.py(口径与边界的唯一正文在其模块 docstring)
  · --stamp:只在三条都成立时才盖章——① .map 存在且解析得出执行区与 Grand
    Totals;② .map 不早于指纹范围内任何源码的 mtime;③ 受管区占比不超过
    mem-budget 用的同一常数。凭证因此不是随手可写的。
  · --local:工作树指纹必须等于凭证指纹,否则 FAIL(="改了代码但体积从没测过")
  · --ci:不需要 Keil 也能判的两条——同码性 + 按凭证记录的数字复核 92% 红线
  · --show:排障用。指纹 = git ls-files 范围内逐文件 sha256(路径+内容) 再聚合,
    覆盖 App/ Drivers/ Src/ Inc/ Lib/ Middlewares/ test/board/ 与 MDK-ARM 的 .sct/.s
- 新增 scripts/size_attestation.json:本树的凭证(由本树一次真实 @build 盖戳)
- scripts/ram_baseline.py:--update 顺手盖戳——"每次 @build 后跑 --update"是既有
  纪律,不新增人工步骤;在函数内 import 防成环;盖戳失败只告警、不改 --update
  返回值(新鲜度由门禁自己 fail-closed 兜)。_util/_read_tsv 提为公开名
  region_pct / read_tsv_rows 供其复用(体积口径禁第二处定义);--gate 的无 .map
  文案改成明说"SKIP 不等于 PASS"并指向兜底路径。
- scripts/ci_local.py:新增 L0c 体积门禁步(fail-closed、无条件照跑、约 0.3 s)
  与 --size-only 开关;docstring 里把"无 .map 时跳过两根钩子"的语义补全。
- .gitea/workflows/ci.yml:L0 作业末尾加「体积门禁」步(if: always(),无需 Keil);
  主干 push 拦红、其余事件 --warn-only(分支上代码尚未定型,拦红只造噪音,还会
  诱发多条道同时抢写 scripts/ 台账);SKIP 名单补 mem-budget,让它诚实显示
  Skipped 而不是假 Passed;文件头的"门禁盲区声明"改写为"已关闭为强制路径 +
  仍守不住的三项"。run: 块全部纯 ASCII(PS 5.1 无 BOM 按 GBK 解码的实测坑)。
- scripts/README.md:登记新脚本与凭证文件,补用法行。

验证(工作树 _wt/A5-size-gate,分支 task/A5-size-gate)
- @build 0 错 0 警:Code=213780 RO=18860 RW=1204 ZI=131852——与 ram_baseline.tsv
  的 2026-10-24 行逐项相同(本改动不碰固件源码,指纹不变,零体积增量)。
- 门禁实跑全 0:check-yaml / trailing-whitespace / end-of-file-fixer /
  check-added-large-files / no-mojibake / id-check / doc-links / mem-budget。
- 负向 8 例全过:改凭证指纹 → --local 与 --ci 都 rc=1;凭证占比改成 95% →
  --ci rc=1,加 --warn-only 则 rc=0;凭证写成非法 JSON → rc=1;用 mtime 回拨的
  .map 去 --stamp → 拒绝且不动凭证;把区 max 改小到必然超线再 --stamp → 同样拒绝。
- 正向:--stamp 之后 --local / --ci 均 rc=0,并与 tsv 末行互证同源。
- --update 的盖戳联动单独验(把 TSV_PATH 与凭证指到仓库内已 gitignore 的
  ci-artifacts/ 探针文件,跑完清理,本树台账未被测试污染)。
- ci_local 全量:L0.5 33 runner / 2227 断言全绿、gitlint 10/10、L0c rc=0。L0 里
  test-cppcheck 与 test-coverage 各报过一次"files were modified"——单独重跑两条
  均 rc=0 且工作树无改动,判定为三条道并行跑主机单测时 %TEMP% 里按套件名派生
  的 obj/exe 互相踩踏(harness 产物名全机共用一个 TEMP 目录),非本改动引入。

RAM 增量:0 B(未动固件源码,凭证数字与本树一次真实链接一致)。
gate:全部通过,未使用 --no-verify。

遗留 / 需要 lead 裁定
1. 合入后主干 push 会要求"同码测量":done 流程在 @build 0/0 之后要多跑一条
   python scripts/ram_baseline.py --update,并把 scripts/size_attestation.json
   (数字变了则连 scripts/ram_baseline.tsv)一并提交。这就是"强制"的含义,但
   确实新增半步——若你想先只在本地强制(L0c),我把 CI 那步改成 --warn-only。
2. 文档口径同步我没做(docs/任务规划/**、docs/门禁说明/**、docs/CI/README.md
   归主树):A-5 销项时建议把"CI 不守体积"改为"CI 守同码性 + 红线,绝对数字仍
   由本地真实构建产生",正文引用 check_size_gate.py 的模块 docstring。
3. 编译器选项(uvprojx)刻意不进指纹:UV4 每次构建改写它且不入库,进指纹会让
   门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工
   重测——已写进边界正文。
2026-10-25 03:51:19 +08:00

391 lines
17 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 -*-
"""体积门禁强制路径(A-5 残余,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 的 L0c 步:工作树指纹必须等于凭证指纹。
--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 ls-files`(与 layer-deps / id-check 同口径),逐文件 sha256(路径+内容) 再
聚合;未跟踪文件与构建产物天然不在内。
用法(仓库根):
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
设计依据:docs/任务规划/测试与架构专项.md(A-5 残余)、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"
SCHEMA = 1
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 tracked_files():
"""受管文件列表(git 不可用时退回目录遍历,同 check_layer_deps.py)。"""
try:
out = subprocess.run(["git", "ls-files", "-z"], cwd=str(REPO_ROOT),
capture_output=True, check=True)
except Exception: # noqa: BLE001 git 缺失/不在仓库内
files = []
for root, dirs, names in os.walk(REPO_ROOT):
dirs[:] = [d for d in dirs if d not in (".git", ".venv", "build")]
for n in names:
rel = os.path.relpath(os.path.join(root, n), REPO_ROOT)
files.append(rel.replace(os.sep, "/"))
return sorted(files)
return sorted(p.decode("utf-8", "replace") for p in out.stdout.split(b"\0") if p)
def scoped_files():
return [f for f in tracked_files() if in_scope(f)]
def tree_fingerprint():
"""(指纹, 文件数, 缺失数)。受管但已删除的文件以哨兵入指纹,保证增删也变指纹。"""
h = hashlib.sha256()
n = miss = 0
for rel in scoped_files():
n += 1
try:
blob = (REPO_ROOT / rel).read_bytes()
except OSError:
miss += 1
blob = b"<deleted>"
h.update(rel.encode("utf-8", "surrogateescape"))
h.update(b"\0")
h.update(hashlib.sha256(blob).digest())
return h.hexdigest(), n, miss
def newest_source_mtime():
newest = 0.0
for rel in scoped_files():
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
fp, n, miss = tree_fingerprint()
write_attestation({
"schema": SCHEMA,
"fingerprint": fp,
"fingerprint_files": n,
"fingerprint_missing": miss,
"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 + " 体积门禁未通过(A-5 强制路径)", 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, miss = tree_fingerprint()
problems = []
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 再提交",
]
if miss:
problems.append(" (注:当前树缺 %d 个受管文件,可能 checkout 不完整)" % miss)
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, miss = tree_fingerprint()
print("当前受管源码指纹:%s(%d 文件,缺失 %d)" % (fp, n, miss))
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="体积门禁强制路径(A-5 残余:同码凭证 + 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 自查:工作树须与测量同码")
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())