#!/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) 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 的自适应逻辑一致) 退出码: 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 --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_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 主机侧单测") 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 not args.l05_only: rc = run_l0() # 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段; # 即使 L0 已失败也照跑,一次看全所有违规项。 rc_msg = run_gitlint(args.commits) if rc != 0 or rc_msg != 0: if not args.keep_going: print("") print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)") return 1 rc = rc or rc_msg 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 + L0.5)") else: print(" [FAIL] 本地 CI 有未通过项(见上方输出)") print("=" * 64) return rc if __name__ == "__main__": sys.exit(main())