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 每次构建改写它且不入库,进指纹会让
   门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工
   重测——已写进边界正文。
This commit is contained in:
2026-10-25 03:51:19 +08:00
parent 658fbe7a72
commit e25e51c378
6 changed files with 628 additions and 42 deletions
+50 -5
View File
@@ -9,6 +9,9 @@
覆盖范围(与 CI 完全一致):
L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint)
L0c 体积门禁(A-5 残余的强制路径,2026-10-25)= `scripts/check_size_gate.py --local`
**fail-closed**:没有体积同码凭证、或代码与测量不同码即红——不再"没跑=通过"。
CI 侧的「体积门禁」步跑的是同一条实现(那条无需 Keil,见 .gitea/workflows/ci.yml)。
L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py`
L0.5c 覆盖率(**opt-in:`--coverage`**,2026-10-23)= `scripts/check_test_coverage.py`
默认**不跑**、CI 也**不跑**:它依赖 VS 可选的「代码覆盖率工具」组件,且与 L0.5 那 16 个
@@ -24,7 +27,9 @@
刻意**不含**(与 CI 同理由,见 workflow 文件头):
- `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线)
- `mem-numbers` / `mem-budget` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map
就照常跑**,与 CI 的自适应逻辑一致)
就照常跑**,与 CI 的自适应逻辑一致)。⚠ 这两根闸在"无 .map"时是 **SKIP 而不是
PASS**(等于这一趟什么都没查);那个缺口由上面的 L0c 体积门禁补住:同码凭证
+ 92% 红线复核,无需 Keil 也能判,且 fail-closed。
退出码:
0 = 全部通过(或不适用项已按规则跳过)
@@ -39,6 +44,7 @@
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' scripts/ci_local.py
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --size-only
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验条数
@@ -284,6 +290,37 @@ def run_gitlint(count):
return 1 if failed else 0
def run_size_gate():
"""L0c:体积门禁(A-5 残余的强制路径)。返回退出码(0/1/2)。
为什么必须单列一步(2026-10-25):`mem-numbers` / `mem-budget` 的事实源是 Keil
`.map`,无 `.map` 时两者**自适应 SKIP 并返回 0**——"改了代码但根本没编译"
这条最常见的漏测路径因此在本地也是绿的,CI 更是整根钩子都不跑。A-5 说的正是
这个**"没跑 = 通过"**的盲区。
本步跑 `scripts/check_size_gate.py --local`,不需要 Keil:它校验**体积同码凭证**
(`scripts/size_attestation.json`,只能由"新鲜 .map + 未超 92% 线"的测量写出),
于是"代码变了但体积从没测过"在这里必然拦红,并给出可照做的修复命令。
CI 的「体积门禁」步用的是同一条实现,两边不会各说一套口径。
"""
print("")
print("=" * 64)
print(" L0c 体积门禁(A-5 残余:同码凭证 + 92% 红线,无需 Keil)")
print("=" * 64)
script = REPO_ROOT / "scripts" / "check_size_gate.py"
if not script.is_file():
# 缺脚本 = 这一层没装上,按失败处理(fail-closed,与"没跑=通过"相反)
print("[ERROR] 缺 scripts/check_size_gate.py —— 体积门禁无法执行,按失败处理")
print("[SUMMARY] size-gate: rc=2 (gate script missing)")
return 2
rc = _run([PY, str(script), "--local"])
if rc == 1:
print(" (1 = 没有凭证,或代码与测量不同码:@build 0/0 之后跑 "
"python scripts/ram_baseline.py --update)")
print("[SUMMARY] size-gate: rc={}".format(rc))
return rc
def run_l05(args):
"""L0.5:主机侧单测。返回退出码(0/1/2)。"""
print("")
@@ -371,6 +408,8 @@ def main(argv=None):
g = ap.add_mutually_exclusive_group()
g.add_argument("--l0-only", action="store_true", help="只跑 L0 静态门禁")
g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测")
g.add_argument("--size-only", action="store_true",
help="只跑 L0c 体积门禁(A-5 强制路径;约 0.3 s,无需 Keil)")
ap.add_argument("--coverage", action="store_true",
help="额外跑主机单测覆盖率(约 25 s;需 VS 代码覆盖率组件;默认不跑,CI 也不跑)")
ap.add_argument("--keep-going", action="store_true",
@@ -403,17 +442,23 @@ def main(argv=None):
rc = 0
try:
if args.size_only:
return run_size_gate()
if not args.l05_only:
rc = run_l0()
# 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段;
# 即使 L0 已失败也照跑,一次看全所有违规项。
rc_msg = run_gitlint(args.commits)
if rc != 0 or rc_msg != 0:
# L0c 体积门禁同样**无条件照跑**:它回答的是"这份代码测过体积没有",
# 与 L0/L0b 的结论正交,且只要 0.3 s——少跑一次就是 A-5 那个盲区。
rc_size = run_size_gate()
if rc != 0 or rc_msg != 0 or rc_size != 0:
if not args.keep_going:
print("")
print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)")
print("[FAIL] L0 / L0b / L0c 有未通过项 —— 中止"
"(--keep-going 可继续跑 L0.5)")
return 1
rc = rc or rc_msg
rc = rc or rc_msg or rc_size
if not args.l0_only:
rc05 = run_l05(args)
rc = rc or rc05
@@ -426,7 +471,7 @@ def main(argv=None):
print("")
print("=" * 64)
if rc == 0:
print(" [OK] 本地 CI 全部通过(L0 + L0.5)")
print(" [OK] 本地 CI 全部通过(L0 + L0b + L0c + L0.5)")
else:
print(" [FAIL] 本地 CI 有未通过项(见上方输出)")
print("=" * 64)