ci(gates): 覆盖率总闸与全量 cppcheck 移到远端 CI 强制(提交期实跑 48 根)

用户裁定 2026-10-28:慢钩子不再压在每个 git commit 上,由远端
CI 强制;本地等价入口 = scripts/ci_local.py 的 manual 段。

实测三根耗时:cppcheck 20.5 s、test-cppcheck 38.1 s、
test-coverage 105.2 s,合计 ~165 s ≈ 一次带代码提交耗时的
三分之二(test-coverage 的 files: 覆盖 App/,改一行就跑光 40 套)。

- .pre-commit-config.yaml:cppcheck / test-cppcheck /
  test-coverage 加 stages: [manual] + 理由注释;替换掉
  test-coverage 上方「没写 stages → 所有阶段都跑、输出出现两次」
  的旧坑注释(固定 manual 后不再有这一坑)。
- .gitea/workflows/ci.yml:static-gates job 在静态门禁步之后
  新增「手动阶段门禁(覆盖率总闸 + 全量 cppcheck)」步,
  if: always(),逐根 --hook-stage manual(pre_commit run 不支持
  多 id),日志 ci-artifacts/manual-stage.log + 归档 + STEP_SUMMARY,
  rc≠0 即失败;run 块纯 ASCII。
- scripts/ci_local.py:新增 MANUAL_STAGE_HOOKS 与 run_manual_stage()
  (静态段之后默认跑),删 run_coverage() 死代码,--coverage
  降级为兼容别名。
- AGENTS.md + 12 篇 docs/scripts/README:三数同源口径改为
  声明 51 / 提交期 --all-files 实跑 47 / +gitlint = 48,
  其余 3 根在 manual 阶段;历史测量数字只加「当时」不回改。

验证:pre-commit run --all-files = 47 Passed / 0 Failed rc=0;
manual 三根 Passed(test-coverage line 89.8% func 93.2% 41/41 套);
ci_local.py --static-only 全绿;check_doc_links --all
--sections-strict rc=0、mojibake / bom-drift / id-check-new rc=0;
ci.yml yaml.safe_load 通过。未改任何 .c/.h,故未跑 @build。
This commit is contained in:
2026-10-28 23:04:59 +08:00
parent e9cae5fdcd
commit 295eb9911f
16 changed files with 225 additions and 88 deletions
+8 -2
View File
@@ -41,10 +41,10 @@
| 脚本 / 文件 | 作用 |
|---|---|
| `ci_local.py` | **本地跑与 CI 等价的门禁**(静态门禁 pre-commit + 提交信息门禁 + **体积门禁** + 主机侧纯逻辑单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers` / `mem-budget`(⚠ 那是 SKIP 不是通过,缺口由体积门禁兜住);退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 |
| `ci_local.py` | **本地跑与 CI 等价的门禁**(静态门禁 pre-commit 47 根 + **manual 段**(`cppcheck` / `test-cppcheck` / `test-coverage` 三根逐根 `--hook-stage manual`,默认跑)+ 提交信息门禁 + **体积门禁** + 主机侧纯逻辑单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers` / `mem-budget`(⚠ 那是 SKIP 不是通过,缺口由体积门禁兜住);退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 |
| `check_size_gate.py` | **体积门禁的强制路径**:`.map` 只在本地有,钩子无它时自适应 SKIP,于是"没测=通过"。本脚本用**已提交的同码凭证** `scripts/size_attestation.json` 把这条判据变成无需 Keil 也能跑:`--stamp` 只在"`.map` 存在、比全部受管源码新、且未超 92%"时才盖章(已挂在 `ram_baseline.py --update` 里,无新增人工步骤);`--local` 供 ci_local 的体积门禁步;`--ci` 供 runner(同码性 + 按凭证数字复核红线,主干 push 拦红、其余事件 `--warn-only`);`--show` 排障。**口径与边界的唯一正文在本脚本模块 docstring** |
| `size_attestation.json` | (被 `check_size_gate.py` 读写)体积同码凭证:受管源码指纹(`git ls-files` 范围内逐文件 sha256 聚合)+ 测量数字与占比 + `.map` 出处。改固件源码后不重测 → 指纹对不上 → 体积门禁与 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`) |
| `check_test_coverage.py` | 主机单测**覆盖率**(2026-10-23):mscoverage 动态插桩跑 `test/host/**` 全部 40 套,统计自研生产源码行/函数覆盖(并集)+ 与 `test_coverage_baseline.json` 比回退;`--module/--update-baseline/--md/--json/--list`;挂在 `test-coverage` 钩子上(`stages: [manual]`,**`git commit` 不跑**),入口 = 远端 CI「手动阶段门禁」步 / `scripts/ci_local.py` 的 manual 段(`--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 与本地判定会分叉) |
| `ci_runner_bootstrap.ps1` | **本机 runner 运维脚本**(体检 `-Status` / 干跑 `-DryRun` / 部署 `-SetEnv -StartNow`):装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 开机计划任务 + 机器级 `DTU_*` |
@@ -57,8 +57,14 @@
```bash
# 全库跑门禁(用仓库 .venv 的 python,其 pre-commit 随 requirements.txt 安装;
# 其他装有 pre-commit 的解释器可用 DTU_PRECOMMIT_PYTHON 指定)
# 只覆盖 `pre-commit` 阶段的 47 根(`git commit` 实跑 = 47 + gitlint = 48 根)
& '.venv\Scripts\python.exe' -m pre_commit run --all-files
# manual 阶段三根(带 stages: [manual],提交期不跑):一次只能给一个 id
& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual cppcheck
& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual test-cppcheck
& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual test-coverage
# 本地跑与 CI 等价的门禁(静态门禁 + 提交信息门禁 + 体积门禁 + 主机侧纯逻辑单测)
& '.venv\Scripts\python.exe' scripts/ci_local.py
& '.venv\Scripts\python.exe' scripts/ci_local.py --static-only # 只跑静态门禁
+57 -32
View File
@@ -9,13 +9,19 @@
覆盖范围(与 CI 完全一致):
静态门禁(static) = `pre-commit run --all-files` + 提交信息门禁(gitlint)
手动阶段(manual,2026-10-28)= 三根带 `stages: [manual]` 的钩子:
cppcheck(全量)/ test-cppcheck / test-coverage(覆盖率总闸)。
它们**不在 git commit 时跑**(实测合计 ~165 s = 一次带代码提交耗时的三分之二),
提交期只跑快的那些;CI 的 static-gates job 逐根跑同一批钩子(同口径)。
本地复跑:本脚本的 manual 段,或 `python -m pre_commit run --all-files
--hook-stage manual <hook-id>`(一次只能给一个 id)。
体积门禁(size,强制路径,2026-10-25)= `scripts/check_size_gate.py --local`
**fail-closed**:没有体积同码凭证、或代码与测量不同码即红——不再"没跑=通过"。
CI 侧的「体积门禁」步跑的是同一条实现(那条无需 Keil,见 .gitea/workflows/ci.yml)。
主机侧纯逻辑单测(host-unit) = `test/host/**/run_unit_test.py`
主机单测覆盖率(coverage,**opt-in:`--coverage`**,2026-10-23)= `scripts/check_test_coverage.py`
默认**不跑**、CI 也**不跑**:它依赖 VS 可选的「代码覆盖率工具」组件,且与主机侧单测那 16 个
钩子跑的是同一批测试(覆盖率只是换一种看法)。口径与盲区 → docs/测试说明/主机单测覆盖率口径.md
覆盖率口径与盲区 → docs/测试说明/主机单测覆盖率口径.md
(旧旗标 `--coverage` 的独立一步已并入 manual 段:跑的是**同一根** pre-commit 钩子
test-coverage,与 CI 逐字同口径,不再重复跑一遍插桩)
⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游**
的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而
@@ -519,6 +525,42 @@ def run_static_gates():
return rc
MANUAL_STAGE_HOOKS = ("cppcheck", "test-cppcheck", "test-coverage")
def run_manual_stage():
"""手动阶段门禁:三根带 stages: [manual] 的钩子(覆盖率总闸 + 全量 cppcheck)。
为什么单独一段而不并进 static:它们在 .pre-commit-config.yaml 里固定成 manual 阶段
(2026-10-28 用户裁定),`pre-commit run --all-files` 不再覆盖它们;提交期跑它们
实测要 ~165 s。本脚本是"与 CI 等价"的完整自查,所以**默认照跑**。
逐根调用:`pre_commit run <a> <b>` 不支持多 id(实测 argparse 报
"unrecognized arguments"),CI 侧同样是逐根跑(.gitea/workflows/ci.yml)。
"""
print("")
print("=" * 64)
print(" manual 手动阶段门禁(覆盖率总闸 + 全量 cppcheck,提交期不跑)")
print("=" * 64)
env = dict(os.environ)
env.pop("SKIP", None) # 三根都不涉及 Keil 依赖,SKIP 与它们无关
rc_all = 0
for hook in MANUAL_STAGE_HOOKS:
rc, text = _run_capture([PY, "-m", "pre_commit", "run", "--all-files",
"--hook-stage", "manual", hook], env=env)
tail = [l for l in text.splitlines() if l.strip()]
verdict = tail[-1].strip() if tail else "(no output)"
print("[SUMMARY] manual: {} -- rc={} {}".format(hook, rc, verdict[-90:]))
if hook == "test-coverage" and rc == 2:
print(" (rc 2 = 环境错误:多半没装 VS 的「代码覆盖率工具」组件,或需设")
print(" DTU_MS_COVERAGE。口径与排障 → docs/测试说明/主机单测覆盖率口径.md)")
elif hook == "test-coverage" and rc == 1:
print(" (rc 1 = 有单测失败,或已覆盖行/函数相对基线回退;确认无误后")
print(" 跑 scripts/check_test_coverage.py --update-baseline 重登基线)")
rc_all = rc_all or rc
return rc_all
def run_gitlint(count):
"""提交信息门禁:把最近 N 条非 merge 提交的 message 逐条喂给 gitlint 钩子。
@@ -670,30 +712,6 @@ def run_host_unit_tests(args):
return rc_all
def run_coverage():
"""覆盖率附加步骤(opt-in,CI 不跑):主机单测覆盖率 + 基线增量门禁。
单独一步而不并进主机侧纯逻辑单测:那一层是**必须绿**的门禁,覆盖率是**趋势视图**,两者失败含义不同
(前者=测试挂了,后者=覆盖回退了 / 缺工具链)。退出码原样透传,由 main 汇总。
"""
print("")
print("=" * 64)
print(" coverage 主机单测覆盖率(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:
@@ -709,7 +727,8 @@ def main(argv=None):
g.add_argument("--size-only", action="store_true",
help="只跑体积门禁(强制路径;约 0.3 s,无需 Keil)")
ap.add_argument("--coverage", action="store_true",
help="额外跑主机单测覆盖率(约 25 s;需 VS 代码覆盖率组件;默认不跑,CI 也不跑)")
help="[已并入 manual 段] 主机单测覆盖率现由 test-coverage 钩子在 manual 段跑,"
"本旗标保留只为兼容旧命令(不再额外跑一遍)")
ap.add_argument("--preflight-only", action="store_true",
help="只跑钩子解释器预检(只读;缺包时当场指名钩子)")
ap.add_argument("--keep-going", action="store_true",
@@ -762,6 +781,10 @@ def main(argv=None):
if not args.host_unit_only:
rc = run_static_gates()
ran.append("static")
# 手动阶段三根(覆盖率总闸 + 全量 cppcheck):git commit 时不再跑,本脚本照跑——
# 本脚本存在的意义就是"与 CI 等价"。即使静态门禁已失败也照跑,一次看全所有违规项。
rc_manual = run_manual_stage()
ran.append("manual")
# 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段;
# 即使静态门禁已失败也照跑,一次看全所有违规项。
rc_msg = run_gitlint(args.commits)
@@ -770,20 +793,22 @@ def main(argv=None):
# 与静态门禁/提交信息门禁的结论正交,且只要 0.3 s——少跑一次就是那个盲区。
rc_size = run_size_gate()
ran.append("size")
if rc != 0 or rc_msg != 0 or rc_size != 0:
if rc != 0 or rc_manual != 0 or rc_msg != 0 or rc_size != 0:
if not args.keep_going:
print("")
print("[FAIL] 静态门禁 / 提交信息门禁 / 体积门禁有未通过项 —— 中止"
"(--keep-going 可继续跑主机侧纯逻辑单测)")
return 1
rc = rc or rc_msg or rc_size
rc = rc or rc_manual or rc_msg or rc_size
if not args.static_only:
rc_hu = run_host_unit_tests(args)
ran.append("host-unit")
rc = rc or rc_hu
if args.coverage:
rc = rc or run_coverage()
ran.append("coverage")
print("")
print("[INFO] --coverage 已并入 manual 段(test-coverage 钩子,与 CI 同口径),"
"不再重复跑插桩;该旗标保留只为兼容旧命令。")
except FileNotFoundError as e:
print("[ERROR] 环境错误:{}".format(e))
return 2