From e25e51c378722658633525d5871f9b44a93d2da5 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 25 Oct 2026 03:51:19 +0800 Subject: [PATCH] =?UTF-8?q?ci(A-5):=20=E4=BD=93=E7=A7=AF=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E5=88=B6=E8=B7=AF=E5=BE=84=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=90=8C=E7=A0=81=E5=87=AD=E8=AF=81=20+=20CI=20=E6=97=A0?= =?UTF-8?q?=E9=9C=80=20Keil=20=E5=A4=8D=E6=A0=B8=2092%=20=E7=BA=A2?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 每次构建改写它且不入库,进指纹会让 门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工 重测——已写进边界正文。 --- .gitea/workflows/ci.yml | 120 +++++++++-- scripts/README.md | 9 +- scripts/check_size_gate.py | 390 ++++++++++++++++++++++++++++++++++ scripts/ci_local.py | 55 ++++- scripts/ram_baseline.py | 49 +++-- scripts/size_attestation.json | 47 ++++ 6 files changed, 628 insertions(+), 42 deletions(-) create mode 100644 scripts/check_size_gate.py create mode 100644 scripts/size_attestation.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 53302745..4d6f0085 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -10,14 +10,23 @@ # 明确不做的两件事(都是刻意为之): # 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且 L0 的 cppcheck + # 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。 -# 2. `mem-numbers` 钩子 —— 它的事实源是 `MDK-ARM/**/*.map`(Keil 构建产物、已 gitignore), -# 无 `@build` 即无 `.map`。故本 workflow 在**检测不到 .map 时自动跳过该钩子** -# (见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner(如自建带 Keil 的机器)则照常跑。 +# 2. `mem-numbers` / `mem-budget` 两根钩子 —— 事实源都是 `MDK-ARM/**/*.map`(Keil +# 构建产物、已 gitignore),无 `@build` 即无 `.map`。故本 workflow 在**检测不到 +# .map 时自动跳过它们**(见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner 照常跑。 # -# 🔴 门禁盲区声明(S14,2026-10-02):跳过 `mem-numbers` 意味着 **CI 不守 Flash/RAM 体积门禁**。 -# 体积变更(新增任务/缓冲/代码)不会在 CI 被拦下;必须靠**本地 `@build` 后** -# `python scripts/check_mem_numbers.py --scan` 兜底。故该跳过以 `::warning::` 明示 -# (不是弱化的 `::notice::`),并写入 job step-summary 的醒目行。 +# 🔴 体积门禁:盲区已关闭为**强制路径**(A-5 残余,2026-10-25;原 S14 只做到"声明") +# 跳过钩子只解释了"runner 没有 Keil",没解决**"没跑 = 通过"**:钩子在无 .map 时 +# 自适应 SKIP 并返回 0,于是 CI 全绿仍可能被读成"体积也守住了"。现由 L0 作业**最后 +# 一步** `python scripts/check_size_gate.py --ci` 在**不需要 Keil** 的前提下补两条判据: +# · 同码性:`scripts/size_attestation.json` 的源码指纹必须等于本次检出树的指纹, +# 而该凭证只能由"新鲜 .map + 当时未超线"的真实链接测量写出(见 --stamp 三道前置) +# ——等价于"这份代码至少被 Keil 链接并核算过一次"; +# · 红线:该次测量记录的受管区占比 ≤92%,与 `mem-budget` 同一常数同一口径。 +# 强制落点 = **主干 push**(唯一能保证"这就是要发出去的那份"的位置);其余事件降级为 +# `::warning::`——任务分支上的代码尚未定型,拦红只制造噪音,还会诱发多条道同时 +# 抢写 scripts/ 台账。仍守不住的(**口径正文唯一**在 scripts/check_size_gate.py 的 +# 模块 docstring,此处不重复):绝对数字由本地真实构建产生(runner 无 Keil 许可证)、 +# 编译器选项(uvprojx,UV4 每次改写且不入库)不在指纹范围内。 # # 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`, # 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。 @@ -235,28 +244,32 @@ jobs: id: skip shell: powershell run: | - # mem-numbers fact source is the Keil build artifact .map (gitignored). - # No .map => this hook cannot run => skip (not "pass", but "not applicable"). + # mem-numbers / mem-budget fact source is the Keil build artifact .map + # (gitignored). No .map => those hooks cannot run => skip them EXPLICITLY. # - # S14 (2026-10-02): make this GAP loud. mem-numbers is the ONLY gate that - # checks docs/memory numbers against the real .map (Flash/RAM size). When - # it is skipped, CI effectively does NOT guard the size budget -- a silent - # ::notice:: was too easy to miss. So: - # - emit ::warning:: (not ::notice::) so it shows in the run summary - # - write a machine-readable marker file for the summary step - # - append a bold line to the job step-summary + # A-5 (2026-10-25): two honesty fixes here. + # 1) mem-budget used NOT to be listed, yet it self-skips inside the script + # when .map is missing (exit 0) -- so on a Keil-less runner it reported + # "Passed" while checking nothing. A gate that fakes green is worse than + # a gate that is visibly skipped: skip it here so the report says Skipped. + # 2) "skipped" alone is still the A-5 blind spot, so the compensation is a + # real step, not only a warning: see the "size gate" step at the end of + # this job (scripts/check_size_gate.py --ci, no Keil needed: attestation + # parity + 92% re-check on the recorded numbers). Boundary and rationale + # live in that script docstring (single source of truth). $map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue if ($map) { - Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally" + Write-Host "found .map: $($map[0].FullName) -- size hooks run normally" "value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii "sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii - "> Volume gate (mem-numbers): RAN (.map present)" | + "> Volume gates (mem-numbers, mem-budget): RAN (.map present)" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } else { - Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build." - "value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers and mem-budget, so absolute Flash/RAM numbers are NOT recomputed here." + Write-Host "::warning::that gap is no longer silent: the final step of this job runs scripts/check_size_gate.py --ci (A-5 forced path: attestation parity + 92% budget re-check). Locally: scripts/ci_local.py step L0c." + "value=mem-numbers,mem-budget" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii "sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii - "> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." | + "> :warning: Volume gates (mem-numbers, mem-budget): **NOT RUN** -- no `.map` (no Keil build); compensated by the size-gate step below." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } @@ -314,6 +327,71 @@ jobs: } Write-Host "::notice::L0 artifacts kept on runner at: ci-artifacts\l0-pre-commit.log" + # ------------------------------------------------------------ + # A-5 残余的强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map + # 放在 L0 作业**最后**且 if: always()——L0 失败时这条结论同样要看得到, + # 而它自己失败会让整个作业变红(这正是"强制"的含义)。 + - name: 体积门禁(A-5 强制路径:同码凭证 + 92% 红线,无需 Keil) + if: always() + shell: powershell + env: + GATE_REF: ${{ github.ref }} + GATE_EVENT: ${{ github.event_name }} + run: | + # Pure ASCII only (see file header). + # + # WHY THIS STEP EXISTS: mem-numbers / mem-budget read the Keil .map and + # self-skip (exit 0) without it, so "not measured" used to look like + # "passed" -- that is exactly the A-5 blind spot ("CI all green does not + # mean the size budget held"). Declaring it (S14) did not close it. + # + # This step enforces a check that needs NO Keil and NO .map: + # * parity -- scripts/size_attestation.json (a committed attestation + # that --stamp refuses to write unless the .map is present, newer + # than every source file in scope, and inside budget) must + # fingerprint-match THIS checked-out tree => this exact code was + # linked for real at least once; + # * budget -- the region percentages that measurement recorded must + # stay within the same 92% line mem-budget uses. + # + # Enforcement point = trunk push, the only ref guaranteed to be the code + # we ship. Task branches / WIP pull requests run --warn-only instead: the + # code there is still moving, and blocking it would push every lane to + # edit the same scripts/ ledger file at the same time. + New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null + $log = 'ci-artifacts\size-gate.log' + if (-not $env:PYTHON) { + # The environment probe step already failed in that case, so the job is + # red anyway; do not mask that with a second, unrelated error. + Write-Host "::warning::PYTHON unresolved (env step failed) -- size gate cannot run" + exit 0 + } + $trunks = @('refs/heads/v2-on-ui', 'refs/heads/master', 'refs/heads/main') + $enforce = ($env:GATE_EVENT -eq 'push') -and ($trunks -contains $env:GATE_REF) + if ($enforce) { + $mode = @() + $label = 'ENFORCING (trunk push)' + } else { + $mode = @('--warn-only') + $label = 'advisory (non-trunk event)' + } + $saved = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + & $env:PYTHON -s scripts/check_size_gate.py --ci @mode 2>&1 | + Tee-Object -FilePath $log + $rc = $LASTEXITCODE + $ErrorActionPreference = $saved + + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8 + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### Volume gate (A-5 forced path)" -Encoding utf8 + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "size gate: rc=$rc mode=$label (attestation parity + 92% re-check; no Keil)" -Encoding utf8 + Write-Host "::notice::size gate: rc=$rc mode=$label (log: $log)" + if ($rc -ne 0) { + Write-Host "::error::volume gate FAILED: this tree has no matching real Keil measurement, or the measured regions exceed the 92% budget." + Write-Host "::error::fix: run the Keil build locally (0 errors 0 warnings), then 'python scripts/ram_baseline.py --update', and commit scripts/size_attestation.json (scripts/ram_baseline.tsv is refreshed by the same command). Rationale/boundary: scripts/check_size_gate.py docstring." + exit $rc + } + # ------------------------------------------------------------------ # L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过) # ------------------------------------------------------------------ diff --git a/scripts/README.md b/scripts/README.md index 703e6cdf..642799b3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,7 +41,9 @@ | 脚本 / 文件 | 作用 | |---|---| -| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 | +| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + **L0c 体积门禁** + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers` / `mem-budget`(⚠ 那是 SKIP 不是通过,缺口由 L0c 兜住);退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 | +| `check_size_gate.py` | **体积门禁的强制路径(A-5 残余)**:`.map` 只在本地有,钩子无它时自适应 SKIP,于是"没测=通过"。本脚本用**已提交的同码凭证** `scripts/size_attestation.json` 把这条判据变成无需 Keil 也能跑:`--stamp` 只在"`.map` 存在、比全部受管源码新、且未超 92%"时才盖章(已挂在 `ram_baseline.py --update` 里,无新增人工步骤);`--local` 供 ci_local 的 L0c 步;`--ci` 供 runner(同码性 + 按凭证数字复核红线,主干 push 拦红、其余事件 `--warn-only`);`--show` 排障。**口径与边界的唯一正文在本脚本模块 docstring** | +| `size_attestation.json` | (被 `check_size_gate.py` 读写)体积同码凭证:受管源码指纹(`git ls-files` 范围内逐文件 sha256 聚合)+ 测量数字与占比 + `.map` 出处。改固件源码后不重测 → 指纹对不上 → L0c 与 CI 同时拦红 | | `check_test_coverage.py` | 主机单测**覆盖率**(2026-10-23):mscoverage 动态插桩跑 `test/host/**` 全部 16 套,统计自研生产源码行/函数覆盖(并集)+ 与 `test_coverage_baseline.json` 比回退;`--module/--update-baseline/--md/--json/--list`;**不在 pre-commit 里**,入口是 `ci_local.py --coverage`(口径见 `docs/测试说明/主机单测覆盖率口径.md`) | | `ci_gitlint_range.ps1` | CI 侧**提交信息门禁**:逐条校验本次 push/PR 引入的提交(`--all-files` 跑不到 `commit-msg` stage,故必须单独守) | | `ci_find_python.ps1` | CI 侧 Python 探测(候选顺序须与 `ci_local.py` 同序同步,否则 CI 与本地判定会分叉) | @@ -57,10 +59,13 @@ # 其他装有 pre-commit 的解释器可用 DTU_PRECOMMIT_PYTHON 指定) & '.venv\Scripts\python.exe' -m pre_commit run --all-files -# 本地跑与 CI 等价的门禁(L0 + L0.5) +# 本地跑与 CI 等价的门禁(L0 + L0b + L0c + L0.5) & '.venv\Scripts\python.exe' scripts/ci_local.py & '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0 & '.venv\Scripts\python.exe' scripts/ci_local.py --l05-only # 只跑 L0.5 +& '.venv\Scripts\python.exe' scripts/ci_local.py --size-only # 只跑 L0c 体积门禁(A-5 强制路径) +& '.venv\Scripts\python.exe' scripts/check_size_gate.py --show # 体积同码凭证现状(0.3 s,无需 Keil) +& '.venv\Scripts\python.exe' scripts/check_size_gate.py --stamp # 用新鲜 .map 重新盖戳(--update 已自动带做) # 单个脚本 & '.venv\Scripts\python.exe' scripts/check_doc_links.py --all diff --git a/scripts/check_size_gate.py b/scripts/check_size_gate.py new file mode 100644 index 00000000..effed911 --- /dev/null +++ b/scripts/check_size_gate.py @@ -0,0 +1,390 @@ +#!/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"" + 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()) diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 27d3251f..870adeb0 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -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) diff --git a/scripts/ram_baseline.py b/scripts/ram_baseline.py index 2d2ac654..bc7ae4b7 100644 --- a/scripts/ram_baseline.py +++ b/scripts/ram_baseline.py @@ -103,7 +103,8 @@ def collect(map_path): "subsys": subsys, "top": top} -def _util(r): +def region_pct(r): + """区占比(%)。**公开名**:check_size_gate.py 复用同一口径,禁第二套算法。""" return r["size"] / r["max"] * 100 if r["max"] else 0.0 @@ -127,7 +128,7 @@ def render_budget(info): for r in info["region_list"]: gated = r["name"] in GATED_REGIONS label = f"{r['name']} (Flash)" if r["name"] == "ER_IROM1" else r["name"] - u = _util(r) + u = region_pct(r) mark = ("PASS" if u <= THRESHOLD_PCT else "FAIL") if gated else "参考" L.append(f"| {label} | {r['size']:,} B | {r['max']:,} B | " f"{u:.1f}% | {mark} |") @@ -173,8 +174,11 @@ def cmd_gate(args): map_path = _map_or_skip(args) if map_path is None: print("[SKIP] 无 .map(未做 Keil 构建)→ RAM 预算门禁不适用") - print(" (CI 盲区已登记 docs/任务规划/测试与架构专项.md A-5 残余;" - "本地改过代码请先 @build)") + print(" ⚠ **SKIP 不等于 PASS**:本钩子这一趟什么都没检查。") + print(" A-5 残余的强制兜底(无需 Keil,改代码后必然拦红):") + print(" 本地 scripts/ci_local.py 的 L0c 步 / CI 的「体积门禁」步,") + print(" 都跑 scripts/check_size_gate.py(同码凭证 + 92% 红线复核)。") + print(" 改过代码请先 @build,再 python scripts/ram_baseline.py --update。") return 0 info = collect(map_path) if not info["regions"]: @@ -187,7 +191,7 @@ def cmd_gate(args): if not r: fails.append(f"缺执行区 {name}(scatter 变更?)") continue - u = _util(r) + u = region_pct(r) if u > THRESHOLD_PCT: fails.append(f"{name} 占用 {u:.1f}% > {THRESHOLD_PCT:.0f}%" f"({r['size']:,}/{r['max']:,} B)") @@ -222,7 +226,7 @@ def cmd_gate(args): # 软提醒(不拦):tsv 落后 / .map 比源码旧 if TSV_PATH.exists(): - rows = _read_tsv() + rows = read_tsv_rows() ir = info["regions"].get("RW_IRAM1") if rows and ir and int(rows[-1]["iram1"]) != ir["size"]: print("[WARN] ram_baseline.tsv 末行落后于当前 .map —— " @@ -235,7 +239,7 @@ def cmd_gate(args): print("[WARN] .map 早于最新源码——数字来自旧构建,建议先 @build 再提交") ir, cc = info["regions"].get("RW_IRAM1"), info["regions"].get("RW_CCM") - print(f"RAM 预算门禁 PASS:RW_IRAM1 {_util(ir):.1f}% / RW_CCM {_util(cc):.1f}%" + print(f"RAM 预算门禁 PASS:RW_IRAM1 {region_pct(ir):.1f}% / RW_CCM {region_pct(cc):.1f}%" f"(阈值 {THRESHOLD_PCT:.0f}%)") return 0 @@ -268,7 +272,8 @@ TSV_HEADER = ("date\tcode\tro\trw\tzi\ter_irom1\ter_pct\tiram1\tiram1_pct" "\tccm\tccm_pct\ttop_rwzi") -def _read_tsv(): +def read_tsv_rows(): + """tsv 全部行(dict 列表)。**公开名**:check_size_gate.py 用它做凭证互证。""" rows = [] lines = TSV_PATH.read_text(encoding="utf-8").splitlines() if len(lines) < 2: @@ -300,9 +305,9 @@ def cmd_update(args): row = [ today, str(gt["code"]), str(gt["ro_data"]), str(gt["rw_data"]), str(gt["zi_data"]), - str(reg["ER_IROM1"]["size"]), f"{_util(reg['ER_IROM1']):.1f}", - str(reg["RW_IRAM1"]["size"]), f"{_util(reg['RW_IRAM1']):.1f}", - str(reg["RW_CCM"]["size"]), f"{_util(reg['RW_CCM']):.1f}", + str(reg["ER_IROM1"]["size"]), f"{region_pct(reg['ER_IROM1']):.1f}", + str(reg["RW_IRAM1"]["size"]), f"{region_pct(reg['RW_IRAM1']):.1f}", + str(reg["RW_CCM"]["size"]), f"{region_pct(reg['RW_CCM']):.1f}", _top_field(info["top"]), ] lines = (TSV_PATH.read_text(encoding="utf-8").splitlines() @@ -317,8 +322,24 @@ def cmd_update(args): TSV_PATH.write_text("\n".join([lines[0]] + body) + "\n", encoding="utf-8", newline="\n") print(f"已记录 {today}:RW_IRAM1 {reg['RW_IRAM1']['size']:,} B " - f"({_util(reg['RW_IRAM1']):.1f}%) / RW_CCM {reg['RW_CCM']['size']:,} B " - f"({_util(reg['RW_CCM']):.1f}%) → {TSV_PATH.relative_to(REPO_ROOT)}") + f"({region_pct(reg['RW_IRAM1']):.1f}%) / RW_CCM {reg['RW_CCM']['size']:,} B " + f"({region_pct(reg['RW_CCM']):.1f}%) → {TSV_PATH.relative_to(REPO_ROOT)}") + + # A-5 残余(2026-10-25):同一次「真实测量」顺带刷新**体积同码凭证** + # scripts/size_attestation.json——这样「每次 @build 后跑 --update」这条**既有 + # 纪律**同时喂饱那条无需 Keil 也能判的强制门禁(check_size_gate --ci / + # --local,即 ci_local 的 L0c 步与 CI 的体积门禁步),不新增人工步骤。 + # 盖戳失败只告警、不改本命令返回值:凭证新鲜度由门禁自己 fail-closed 兜, + # 失败原因(.map 比源码旧 / 已超线)会在门禁那一侧明确报出来。 + # 为什么在函数内 import:check_size_gate 模块级 import 本模块,反过来即成环。 + try: + from argparse import Namespace + + import check_size_gate + check_size_gate.cmd_stamp(Namespace(map=str(map_path))) + except Exception as exc: # noqa: BLE001 + print(f"[WARN] 体积同码凭证未刷新:{exc}") + print(" 单独补跑:python scripts/check_size_gate.py --stamp") return 0 @@ -326,7 +347,7 @@ def cmd_trend(args): if not TSV_PATH.exists(): print("[WARN] tsv 不存在——先跑 --update 建档") return 0 - rows = _read_tsv() + rows = read_tsv_rows() if not rows: print("[WARN] tsv 为空") return 0 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json new file mode 100644 index 00000000..d6df32d7 --- /dev/null +++ b/scripts/size_attestation.json @@ -0,0 +1,47 @@ +{ + "built_at": "2026-10-25T03:35:00+08:00", + "fingerprint": "5f694d421c6014f421f21f72026df7606acf5d8d79ebe25c9e0d016933e776f7", + "fingerprint_files": 1388, + "fingerprint_missing": 0, + "gated_regions": [ + "RW_IRAM1", + "RW_CCM" + ], + "git_head": "658fbe7a72c51c9a8427a39b0f9ef366726f5ffc", + "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", + "map_mtime": 1792870244, + "pct": { + "ER_IROM1": 22.19, + "RW_CCM": 87.89, + "RW_IRAM1": 57.7, + "UNINIT_CCM": 18.75 + }, + "regions": { + "ER_IROM1": { + "max": 1048576, + "size": 232640 + }, + "RW_CCM": { + "max": 65280, + "size": 57376 + }, + "RW_IRAM1": { + "max": 131072, + "size": 75632 + }, + "UNINIT_CCM": { + "max": 256, + "size": 48 + } + }, + "schema": 1, + "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", + "sizes": { + "code": 213780, + "ro_data": 18860, + "rw_data": 1204, + "zi_data": 131852 + }, + "threshold_pct": 92.0, + "written_by": "scripts/check_size_gate.py --stamp" +}