e25e51c378
背景: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 每次构建改写它且不入库,进指纹会让
门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工
重测——已写进边界正文。
483 lines
21 KiB
Python
483 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""ci_local.py - 本地一键跑"与 CI 等价"的门禁(纯标准库)。
|
||
|
||
为什么需要它(2026-10-01 T-6):
|
||
CI(`.gitea/workflows/ci.yml`)只在 push / PR 时跑,且需要 Gitea Actions +
|
||
Windows runner 就绪。**提交前想自查、或 runner 还没配好时**,用本脚本在本地
|
||
跑同一套判定,命令与退出码与 CI 一一对应。
|
||
|
||
覆盖范围(与 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 个
|
||
钩子跑的是同一批测试(覆盖率只是换一种看法)。口径与盲区 → docs/测试说明/主机单测覆盖率口径.md
|
||
|
||
⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游**
|
||
的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而
|
||
`pre-commit run --all-files` 只跑 pre-commit stage —— 它既不在 --all-files 里,
|
||
CI 也一度漏掉(18 个钩子只报 17 个,且表现为"全绿")。本脚本与 CI 的
|
||
`scripts/ci_gitlint_range.ps1` 同语义:逐条把最近 N 条非 merge 提交的
|
||
message 喂给钩子。
|
||
|
||
刻意**不含**(与 CI 同理由,见 workflow 文件头):
|
||
- `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线)
|
||
- `mem-numbers` / `mem-budget` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map
|
||
就照常跑**,与 CI 的自适应逻辑一致)。⚠ 这两根闸在"无 .map"时是 **SKIP 而不是
|
||
PASS**(等于这一趟什么都没查);那个缺口由上面的 L0c 体积门禁补住:同码凭证
|
||
+ 92% 红线复核,无需 Keil 也能判,且 fail-closed。
|
||
|
||
退出码:
|
||
0 = 全部通过(或不适用项已按规则跳过)
|
||
1 = 有门禁/单测失败
|
||
2 = 环境错误(缺 pre-commit / python 模块等)
|
||
|
||
汇总行(S3,2026-10-02,对齐 CI 的 ::notice::):
|
||
[SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K
|
||
[SUMMARY] gitlint: N commit message(s) checked, M failed
|
||
[SUMMARY] L0.5: K runner(s), A/B assertions passed, fail=F
|
||
|
||
用法(仓库根目录):
|
||
& '.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 # 提交信息校验条数
|
||
|
||
设计依据:`.gitea/workflows/ci.yml`;docs/测试说明/测试开发标准流程.md §9
|
||
创建日期:2026-10-01
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
def _global_python_candidates():
|
||
"""常见**机器级** Python 安装位置(必须与 scripts/ci_find_python.ps1 同序)。
|
||
|
||
两处探测一旦不同步,就会出现"本地能跑、CI 挂"的老毛病:2026-10-11 的
|
||
L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者
|
||
都是死路),而真正装了 pre-commit 的机器级解释器(如
|
||
C:\\ProgramData\\anaconda3\\python.exe)不再被探测。
|
||
"""
|
||
out = []
|
||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||
local = os.environ.get("LOCALAPPDATA")
|
||
pd = os.environ.get("ProgramData", r"C:\ProgramData")
|
||
# 新版本优先:新装的解释器通常才是想要的。
|
||
for ver in ("Python313", "Python312", "Python311", "Python310"):
|
||
out.append(os.path.join(pf, ver, "python.exe"))
|
||
out.append(os.path.join(pf86, ver, "python.exe"))
|
||
if local:
|
||
out.append(os.path.join(local, "Programs", "Python", ver, "python.exe"))
|
||
for dist in ("anaconda3", "miniconda3"):
|
||
out.append(os.path.join(pd, dist, "python.exe"))
|
||
return out
|
||
|
||
|
||
def _resolve_python():
|
||
r"""挑一个真正装了 pre-commit 的解释器。
|
||
|
||
本机可能有多个 python(.venv / 发行版 / 系统),而 **pre-commit 只装在其中一个**。
|
||
按优先级探测(见 docs/环境与部署/本地工具路径环境变量方案.md §1.1):
|
||
1. DTU_PRECOMMIT_PYTHON(显式配置:文件缺失或无 pre-commit 模块 → 立即报
|
||
配置错误,**不静默回退**到别的解释器)
|
||
2. 当前解释器(sys.executable)
|
||
3. 仓库 `.venv\Scripts\python.exe`
|
||
4. PATH 上的 `python`
|
||
5. 常见机器级安装位置(见 _global_python_candidates)
|
||
找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。
|
||
"""
|
||
explicit = os.environ.get("DTU_PRECOMMIT_PYTHON", "").strip()
|
||
if explicit:
|
||
if not os.path.isfile(explicit):
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 已设置但文件不存在: {}".format(explicit))
|
||
print(" 请修正该变量(python.exe 完整路径),或清除该变量改用自动探测。")
|
||
sys.exit(2)
|
||
try:
|
||
cp = subprocess.run([explicit, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 指定的解释器无法执行: {}({})".format(explicit, e))
|
||
sys.exit(2)
|
||
if cp.returncode != 0:
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 指向的解释器没有 pre-commit 模块: {}".format(explicit))
|
||
print(" 请修正该变量(指向装有 pre-commit 的 python),或清除该变量改用自动探测。")
|
||
sys.exit(2)
|
||
return explicit, "(显式配置 DTU_PRECOMMIT_PYTHON)"
|
||
|
||
candidates = [
|
||
sys.executable,
|
||
str(REPO_ROOT / ".venv" / "Scripts" / "python.exe"),
|
||
shutil.which("python"),
|
||
] + _global_python_candidates()
|
||
seen = set()
|
||
tried = []
|
||
for py in candidates:
|
||
if not py or py in seen or not os.path.isfile(py):
|
||
continue
|
||
seen.add(py)
|
||
tried.append(py)
|
||
try:
|
||
cp = subprocess.run([py, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
continue
|
||
if cp.returncode == 0:
|
||
note = None if py == sys.executable else "(自动选用,本解释器无 pre-commit)"
|
||
return py, note
|
||
print("[WARN] 未找到装有 pre-commit 的解释器,已尝试:")
|
||
for py in tried:
|
||
print(" - {}".format(py))
|
||
return sys.executable, None
|
||
|
||
|
||
PY, PY_NOTE = _resolve_python()
|
||
|
||
|
||
def _find_map():
|
||
"""返回 Keil 构建产物 .map 的路径(不存在则 None)。"""
|
||
hits = sorted((REPO_ROOT / "MDK-ARM").rglob("*.map"))
|
||
return hits[0] if hits else None
|
||
|
||
|
||
def _find_unit_runners():
|
||
"""test/host 下所有模块运行器,按路径排序。"""
|
||
return sorted((REPO_ROOT / "test" / "host").rglob("run_unit_test.py"))
|
||
|
||
|
||
def _run(cmd, env=None):
|
||
"""跑子进程,输出直通终端。返回退出码。"""
|
||
print("$ " + " ".join(str(c) for c in cmd))
|
||
cp = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env)
|
||
return cp.returncode
|
||
|
||
|
||
def _run_capture(cmd, env=None):
|
||
"""跑子进程,输出**既直通终端又捕获**(对齐 CI 的 Tee-Object)。
|
||
|
||
返回 (退出码, 全部输出文本)。用于 L0.5 解析各模块汇总行(S3 汇总)。
|
||
"""
|
||
print("$ " + " ".join(str(c) for c in cmd))
|
||
cp = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env,
|
||
capture_output=True)
|
||
try:
|
||
text = cp.stdout.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
text = cp.stdout.decode("utf-8", errors="replace")
|
||
sys.stdout.write(text)
|
||
if cp.stderr:
|
||
try:
|
||
sys.stderr.write(cp.stderr.decode("utf-8"))
|
||
except UnicodeDecodeError:
|
||
sys.stderr.write(cp.stderr.decode("utf-8", errors="replace"))
|
||
return cp.returncode, text
|
||
|
||
|
||
def run_l0():
|
||
"""L0:pre-commit 全部钩子。返回退出码(0/1/2)。"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" L0 静态门禁(pre-commit --all-files)")
|
||
print("=" * 64)
|
||
|
||
env = dict(os.environ)
|
||
# 与 CI 相同的自适应:无 .map(未做 Keil 构建)=> mem-numbers / mem-budget
|
||
# 不适用 => 跳过(两者事实源都是 Keil 产物 .map)
|
||
map_path = _find_map()
|
||
if map_path:
|
||
print("[INFO] 找到 .map:{}".format(map_path.relative_to(REPO_ROOT)))
|
||
print("[INFO] mem-numbers / mem-budget 照常运行")
|
||
else:
|
||
print("[INFO] 未找到 .map(未做 Keil 构建)-> 跳过 mem-numbers / "
|
||
"mem-budget(与 CI 一致)")
|
||
print("[INFO] :warning: 体积门禁未执行——改代码后请本地 @build + "
|
||
"check_mem_numbers.py --scan + ram_baseline.py --gate 兜底")
|
||
skip = env.get("SKIP", "")
|
||
add = "mem-numbers,mem-budget"
|
||
env["SKIP"] = (skip + "," if skip else "") + add
|
||
|
||
rc, text = _run_capture([PY, "-m", "pre_commit", "run", "--all-files"], env=env)
|
||
# 汇总行(对齐 CI 的 ::notice::):统计钩子 Passed/Failed/Skipped
|
||
lines = text.splitlines()
|
||
passed = sum(1 for l in lines if l.rstrip().endswith("Passed"))
|
||
failed = sum(1 for l in lines if l.rstrip().endswith("Failed"))
|
||
skipd = sum(1 for l in lines if l.rstrip().endswith("Skipped"))
|
||
print("[SUMMARY] L0: pre-commit hooks -- passed={} failed={} skipped={}".format(
|
||
passed, failed, skipd))
|
||
return rc
|
||
|
||
|
||
def run_gitlint(count):
|
||
"""提交信息门禁:把最近 N 条非 merge 提交的 message 逐条喂给 gitlint 钩子。
|
||
|
||
与 CI 的 scripts/ci_gitlint_range.ps1 同语义(gitlint 声明
|
||
`stages: [commit-msg]`,`pre-commit run --all-files` 永远跑不到它)。
|
||
返回退出码(0/1/2)。
|
||
"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" L0b 提交信息门禁(gitlint / commit-msg stage,最近 {} 条)".format(count))
|
||
print("=" * 64)
|
||
|
||
def _git(*args):
|
||
return subprocess.run(["git"] + list(args), cwd=str(REPO_ROOT),
|
||
capture_output=True, timeout=60)
|
||
|
||
try:
|
||
out = _git("log", "--no-merges", "--format=%H", "-n", str(count), "HEAD")
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
print("[ERROR] 无法调用 git:{}".format(e))
|
||
return 2
|
||
if out.returncode != 0:
|
||
print("[ERROR] git log 失败:{}".format(
|
||
out.stderr.decode("utf-8", errors="replace").strip()))
|
||
return 2
|
||
|
||
shas = [s.strip() for s in out.stdout.decode("utf-8", errors="replace").splitlines()
|
||
if s.strip()]
|
||
if not shas:
|
||
print("[INFO] 无可校验提交 —— 跳过")
|
||
print("[SUMMARY] gitlint: 0 commit message(s) checked, 0 failed")
|
||
return 0
|
||
|
||
# 消息文件必须写在【仓库内】且【UTF-8 无 BOM】:
|
||
# - 跨盘符会被 pre-commit 直接拒绝(path is on mount 'C:', start on mount 'D:')
|
||
# - BOM 会占掉标题 1 个字符,扭曲 .gitlint 的 100 字符上限
|
||
msg_dir = REPO_ROOT / "ci-artifacts" / "gitlint-local"
|
||
msg_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
checked = 0
|
||
failed = 0
|
||
try:
|
||
for sha in shas:
|
||
checked += 1
|
||
body = _git("log", "-1", "--format=%B", sha)
|
||
title = _git("log", "-1", "--format=%s", sha)
|
||
text = body.stdout.decode("utf-8", errors="replace").rstrip("\r\n")
|
||
title_line = title.stdout.decode("utf-8", errors="replace").strip()
|
||
|
||
msg_path = msg_dir / (sha + ".txt")
|
||
msg_path.write_text(text, encoding="utf-8", newline="\n")
|
||
|
||
print("")
|
||
print("--- {} {}".format(sha[:8], title_line))
|
||
rc, _ = _run_capture([PY, "-m", "pre_commit", "run",
|
||
"--hook-stage", "commit-msg",
|
||
"--commit-msg-filename", str(msg_path),
|
||
"gitlint"])
|
||
if rc != 0:
|
||
failed += 1
|
||
print("[FAIL] gitlint 拒绝该提交信息:{}".format(title_line))
|
||
finally:
|
||
shutil.rmtree(str(msg_dir), ignore_errors=True)
|
||
|
||
print("[SUMMARY] gitlint: {} commit message(s) checked, {} failed".format(
|
||
checked, failed))
|
||
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("")
|
||
print("=" * 64)
|
||
print(" L0.5 主机侧纯逻辑单测(MSVC,零硬件)")
|
||
print("=" * 64)
|
||
|
||
runners = _find_unit_runners()
|
||
if not runners:
|
||
print("[WARN] 未找到 test/host/**/run_unit_test.py —— 跳过")
|
||
return 0
|
||
|
||
rc_all = 0
|
||
runner_count = 0
|
||
sum_pass = 0
|
||
sum_total = 0
|
||
for r in runners:
|
||
runner_count += 1
|
||
rel = r.relative_to(REPO_ROOT)
|
||
print("")
|
||
print("--- 运行:{} ---".format(rel))
|
||
rc, text = _run_capture([PY, str(r)])
|
||
# 解析模块自身汇总行,如 "=== System Clock Tests: 100/100 PASSED (failed=0) ==="
|
||
m = None
|
||
for line in text.splitlines():
|
||
mm = re.search(r":\s*(\d+)/(\d+)\s+PASSED\s+\(failed=(\d+)\)", line)
|
||
if mm:
|
||
m = mm
|
||
if m:
|
||
sum_pass += int(m.group(1))
|
||
sum_total += int(m.group(2))
|
||
print("[SUMMARY] {}: {}/{} PASSED (failed={})".format(
|
||
r.parent.name, m.group(1), m.group(2), m.group(3)))
|
||
else:
|
||
print("[WARN] {} 未输出 '=== ... N/M PASSED ===' 汇总行".format(rel))
|
||
if rc != 0:
|
||
print("[FAIL] {}(退出码 {})".format(rel, rc))
|
||
if rc == 2:
|
||
print(" (退出码 2 = 环境错误:多半是本机未装 MSVC,见 "
|
||
"docs/测试说明/测试开发标准流程.md §4.2)")
|
||
rc_all = 1
|
||
if not args.keep_going:
|
||
break
|
||
else:
|
||
print("[OK] {}".format(rel))
|
||
|
||
print("[SUMMARY] L0.5: {} runner(s), {}/{} assertions passed, fail={}".format(
|
||
runner_count, sum_pass, sum_total, rc_all))
|
||
return rc_all
|
||
|
||
|
||
def run_coverage():
|
||
"""覆盖率附加步骤(opt-in,CI 不跑):主机单测覆盖率 + 基线增量门禁。
|
||
|
||
单独一步而不并进 L0.5:L0.5 是**必须绿**的门禁,覆盖率是**趋势视图**,两者失败含义不同
|
||
(前者=测试挂了,后者=覆盖回退了 / 缺工具链)。退出码原样透传,由 main 汇总。
|
||
"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" L0.5c 主机单测覆盖率(mscoverage 动态插桩 + 基线增量门禁)")
|
||
print("=" * 64)
|
||
script = REPO_ROOT / "scripts" / "check_test_coverage.py"
|
||
if not script.is_file():
|
||
print("[WARN] 缺 scripts/check_test_coverage.py —— 跳过该步")
|
||
return 0
|
||
rc = _run([PY, str(script)])
|
||
if rc == 2:
|
||
print(" (退出码 2 = 环境错误:多半没装 VS 的「代码覆盖率工具」组件,或需设")
|
||
print(" DTU_MS_COVERAGE。口径与排障 → docs/测试说明/主机单测覆盖率口径.md)")
|
||
elif rc == 1:
|
||
print(" (退出码 1 = 有单测失败,或已覆盖行/函数相对基线回退;确认无误后")
|
||
print(" 跑 scripts/check_test_coverage.py --update-baseline 重登基线)")
|
||
return rc
|
||
|
||
|
||
def main(argv=None):
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
ap = argparse.ArgumentParser(
|
||
description="本地跑与 CI 等价的门禁(L0 + L0.5,纯标准库)")
|
||
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",
|
||
help="某步失败也继续跑后续步骤(默认首错即停)")
|
||
ap.add_argument("--commits", type=int, default=10, metavar="N",
|
||
help="提交信息门禁(gitlint)校验最近 N 条非 merge 提交(默认 10)")
|
||
args = ap.parse_args(argv)
|
||
|
||
print("=== 本地 CI(等价 .gitea/workflows/ci.yml)===")
|
||
print("仓库:{}".format(REPO_ROOT))
|
||
print("Python:{}".format(PY))
|
||
if PY_NOTE:
|
||
print(" {}".format(PY_NOTE))
|
||
|
||
# 环境预检:pre-commit 必须可用(CI 里由"环境自检"步骤拦)
|
||
try:
|
||
probe = subprocess.run([PY, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
print("[ERROR] 无法调用 pre-commit:{}".format(e))
|
||
return 2
|
||
if probe.returncode != 0:
|
||
print("[ERROR] 该解释器没有 pre-commit 模块。请任选其一:")
|
||
print(" - 用装了 pre-commit 的解释器跑本脚本(如 "
|
||
"& '.venv\\Scripts\\python.exe' scripts/ci_local.py)")
|
||
print(" - 或设置 DTU_PRECOMMIT_PYTHON 指向该解释器")
|
||
print(" (见 docs/环境与部署/本地工具路径环境变量方案.md)")
|
||
print(" - 或在当前环境安装:pip install pre-commit")
|
||
return 2
|
||
|
||
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)
|
||
# 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 / L0b / L0c 有未通过项 —— 中止"
|
||
"(--keep-going 可继续跑 L0.5)")
|
||
return 1
|
||
rc = rc or rc_msg or rc_size
|
||
if not args.l0_only:
|
||
rc05 = run_l05(args)
|
||
rc = rc or rc05
|
||
if args.coverage:
|
||
rc = rc or run_coverage()
|
||
except FileNotFoundError as e:
|
||
print("[ERROR] 环境错误:{}".format(e))
|
||
return 2
|
||
|
||
print("")
|
||
print("=" * 64)
|
||
if rc == 0:
|
||
print(" [OK] 本地 CI 全部通过(L0 + L0b + L0c + L0.5)")
|
||
else:
|
||
print(" [FAIL] 本地 CI 有未通过项(见上方输出)")
|
||
print("=" * 64)
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|