295eb9911f
用户裁定 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。
828 lines
40 KiB
Python
828 lines
40 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""ci_local.py - 本地一键跑"与 CI 等价"的门禁(纯标准库)。
|
||
|
||
为什么需要它(2026-10-01):
|
||
CI(`.gitea/workflows/ci.yml`)只在 push / PR 时跑,且需要 Gitea Actions +
|
||
Windows runner 就绪。**提交前想自查、或 runner 还没配好时**,用本脚本在本地
|
||
跑同一套判定,命令与退出码与 CI 一一对应。
|
||
|
||
覆盖范围(与 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`
|
||
覆盖率口径与盲区 → 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` 里查不到这一行),而
|
||
`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**(等于这一趟什么都没查);那个缺口由上面的体积门禁(size)补住:同码凭证
|
||
+ 92% 红线复核,无需 Keil 也能判,且 fail-closed。
|
||
|
||
退出码:
|
||
0 = 全部通过(或不适用项已按规则跳过)
|
||
1 = 有门禁/单测失败
|
||
2 = 环境错误(缺 pre-commit / python 模块等;钩子解释器预检未过也是 2)
|
||
|
||
汇总行(2026-10-02 起,对齐 CI 的 ::notice::):
|
||
[SUMMARY] static: pre-commit hooks -- passed=N failed=M skipped=K
|
||
[SUMMARY] gitlint: N commit message(s) checked, M failed
|
||
[SUMMARY] host-unit: 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 --static-only
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --size-only
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --host-unit-only
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --preflight-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 的
|
||
静态门禁全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者
|
||
都是死路),而真正装了 pre-commit 的机器级解释器(如
|
||
C:\\ProgramData\\anaconda3\\python.exe)不再被探测。
|
||
|
||
本机实测矩阵(哪个解释器装了哪些包、钩子实际跑在谁身上)见
|
||
docs/环境与部署/本地工具路径环境变量方案.md §5:提交期钩子由 .git/hooks/pre-commit
|
||
里的 INSTALL_PYTHON 钉死在仓库 .venv,而 language: system 的 entry 用裸 python 时
|
||
**不继承**它——所以依赖第三方包的脚本必须像本函数这样自己解析解释器。
|
||
"""
|
||
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()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 钩子解释器预检(道G task-33,2026-10-27)
|
||
#
|
||
# 为什么要这一道:提交期"钩子跑在谁身上"有两个互不相干的来源——
|
||
# · pre-commit 本体跑在 .git/hooks/pre-commit 里写死的 INSTALL_PYTHON 上(本机 = 仓库 .venv);
|
||
# · 而 language: system 的 entry 只要写裸 python,pre-commit **不改 PATH**,那条脚本就
|
||
# 落到 PATH 首位的解释器(本机实测 3.14.7,一个第三方包都没装)。
|
||
# 今天没炸是两件事同时成立的结果:那批裸 python 条目全只用标准库,且唯一需要第三方包的那条
|
||
# 脚本自己解析解释器。矩阵、成因与新增 entry 的硬规则见
|
||
# docs/环境与部署/本地工具路径环境变量方案.md §5(含 §5.1 硬规则、§5.2 修复死顺序与回滚)。
|
||
# 本预检把那条隐性依赖变成显式闸:一旦凑齐「entry 用裸 python + 脚本需要第三方包 + 脚本不
|
||
# 自带解释器解析 + PATH 首位没装那个包」四件事,就当场 exit 2 指名是哪根钩子缺什么,
|
||
# 而不是等跑到那根钩子才崩——半路崩会被误读成"代码有问题",白查一小时。
|
||
#
|
||
# 只读:不改环境、不装包、不写仓库。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: 常见第三方包名 -> 导入名(不在表里的按 sys.stdlib_module_names 反判)
|
||
_THIRD_PARTY_HINTS = ("fontTools", "PIL", "serial", "yaml", "numpy", "pandas", "requests")
|
||
|
||
|
||
def _hook_bare_python_entries():
|
||
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path)]。
|
||
|
||
只收 language: system 且 entry 以裸 python / python3 开头的条目——这类条目的解释器
|
||
由 PATH 决定,不受 pre-commit 本体那个 INSTALL_PYTHON 影响。配置文件是手写的规整
|
||
格式,这里按行扫即可(不依赖 PyYAML:那正是本预检要防的"依赖解释器"问题)。
|
||
"""
|
||
cfg = REPO_ROOT / ".pre-commit-config.yaml"
|
||
if not cfg.is_file():
|
||
return []
|
||
out = []
|
||
cur_id = None
|
||
cur_entry = None
|
||
cur_lang = None
|
||
for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
s = raw.strip()
|
||
if s.startswith("#"):
|
||
continue
|
||
m = re.match(r"^-\s+id:\s*(\S+)", s)
|
||
if m:
|
||
cur_id, cur_entry, cur_lang = m.group(1), None, None
|
||
continue
|
||
m = re.match(r"^entry:\s*(.+)$", s)
|
||
if m:
|
||
cur_entry = m.group(1).strip()
|
||
continue
|
||
m = re.match(r"^language:\s*(\S+)", s)
|
||
if m:
|
||
cur_lang = m.group(1)
|
||
if cur_lang == "system" and cur_entry and cur_id:
|
||
toks = cur_entry.split()
|
||
if toks and re.match(r"^python(3)?$|^py$", toks[0]) and len(toks) > 1:
|
||
out.append((cur_id, toks[1].strip("'\"")))
|
||
continue
|
||
return out
|
||
|
||
|
||
def _script_third_party(script_rel):
|
||
"""返回 {第三方包名: 由哪个脚本引入};纯标准库且下游也不需要的脚本返回空字典。
|
||
|
||
判据:顶层导入名既不在 sys.stdlib_module_names 里、也不在仓库内解析得到(同目录/
|
||
scripts/tools 下的兄弟模块算仓库内),就算第三方。老解释器没有
|
||
stdlib_module_names 时退到 _THIRD_PARTY_HINTS 白名单,宁可少报不误报。
|
||
顺带沿"脚本里以字符串出现的仓库内 .py"下探一层(见 _referenced_scripts),
|
||
否则漏掉 2026-10-26 那类"钩子干净、被它拉起的生成器缺包"的真事故。
|
||
"""
|
||
forced = _direct_third_party(script_rel)
|
||
hinted = {}
|
||
for name, src in _collect_third_party(script_rel, 2, set()).items():
|
||
if name not in forced:
|
||
hinted[name] = src
|
||
lazy = {}
|
||
for name, src in _collect_lazy_third_party(script_rel, 2, set()).items():
|
||
if name not in forced and name not in hinted:
|
||
lazy[name] = src
|
||
return forced, hinted, lazy
|
||
|
||
|
||
def _is_repo_internal(script_dir, name):
|
||
"""该导入名能否在仓库内解析到(兄弟模块 / scripts / tools / 主机单测公共件)。"""
|
||
search_dirs = [script_dir, REPO_ROOT / "scripts", REPO_ROOT / "tools",
|
||
REPO_ROOT / "test" / "host" / "common", REPO_ROOT]
|
||
return any((d / (name + ".py")).is_file() or (d / name).is_dir() for d in search_dirs)
|
||
|
||
|
||
def _direct_third_party(script_rel):
|
||
"""本脚本直接 import 的第三方顶层模块 -> 该脚本路径(判据见 _script_third_party 的头注释)。"""
|
||
path = REPO_ROOT / script_rel
|
||
if not path.is_file():
|
||
return {}
|
||
text = path.read_text(encoding="utf-8", errors="replace")
|
||
mods = set()
|
||
# 只认**模块级**(第 0 列)的 import:函数体里的 try/惰性导入不是"加载必崩",
|
||
# 按它报会把好钩子判成坏钩子(实测 check_font_charset 就在 3.14 上跑得过,
|
||
# 因为 make_font 的 fontTools 是函数内惰性导入)。
|
||
for m in re.finditer(r"^(?:import|from)[ \t]+([A-Za-z_][\w.]*)", text, re.M):
|
||
mods.add(m.group(1).split(".")[0])
|
||
std = getattr(sys, "stdlib_module_names", None)
|
||
out = {}
|
||
for name in sorted(mods):
|
||
if std is not None:
|
||
if name in std:
|
||
continue
|
||
elif name not in _THIRD_PARTY_HINTS:
|
||
continue
|
||
if _is_repo_internal(path.parent, name):
|
||
continue
|
||
out[name] = script_rel
|
||
return out
|
||
|
||
|
||
def _collect_lazy_third_party(script_rel, depth, seen):
|
||
"""惰性 / 条件导入的第三方包(函数体内的 import)——**只列候选,不作判决**。
|
||
|
||
静态判不了它到底会不会被走到,所以不当失败依据;但值得摊开看:2026-10-26 那次
|
||
"No module named fontTools" 正是这种形态(钩子脚本自己只 import 标准库,被它拉起的
|
||
生成器在函数里导 fontTools)。判 FAIL 用 _direct_third_party,本函数只负责"看得见"。
|
||
"""
|
||
if script_rel in seen or not (REPO_ROOT / script_rel).is_file():
|
||
return {}
|
||
seen.add(script_rel)
|
||
text = (REPO_ROOT / script_rel).read_text(encoding="utf-8", errors="replace")
|
||
mods = set()
|
||
for m in re.finditer(r"^[ \t]+(?:import|from)[ \t]+([A-Za-z_][\w.]*)", text, re.M):
|
||
mods.add(m.group(1).split(".")[0])
|
||
std = getattr(sys, "stdlib_module_names", None)
|
||
path = REPO_ROOT / script_rel
|
||
found = {}
|
||
for name in sorted(mods):
|
||
if (name in std) if std is not None else (name not in _THIRD_PARTY_HINTS):
|
||
continue
|
||
if _is_repo_internal(path.parent, name):
|
||
continue
|
||
found[name] = script_rel
|
||
if depth > 0:
|
||
for ref in _referenced_scripts(script_rel, text):
|
||
for name, src in _collect_lazy_third_party(ref, depth - 1, seen).items():
|
||
found.setdefault(name, src)
|
||
return found
|
||
|
||
|
||
def _referenced_scripts(script_rel, text):
|
||
"""脚本里以字符串出现的仓库内 .py —— subprocess / importlib 的下游目标。
|
||
|
||
为什么要顺着找一层:2026-10-26 那次真事故就是"钩子本身只 import 标准库、被它拉起的
|
||
字体链脚本要 fontTools",只看直接 import 会漏判(详见 docs/环境与部署/
|
||
本地工具路径环境变量方案.md §5.1)。
|
||
"""
|
||
out = []
|
||
for m in re.finditer(r"([A-Za-z0-9_./\\-]+\.py)", text):
|
||
rel = m.group(1).lstrip("./\\").replace("\\", "/")
|
||
if (REPO_ROOT / rel).is_file() and rel != script_rel:
|
||
out.append(rel)
|
||
return sorted(set(out))
|
||
|
||
|
||
def _collect_third_party(script_rel, depth, seen):
|
||
"""顺着下游目标合并第三方包,返回 {包名: 由哪个脚本引入}(深度上限 2,防环)。"""
|
||
if script_rel in seen or not (REPO_ROOT / script_rel).is_file():
|
||
return {}
|
||
seen.add(script_rel)
|
||
text = (REPO_ROOT / script_rel).read_text(encoding="utf-8", errors="replace")
|
||
found = dict(_direct_third_party(script_rel))
|
||
if depth <= 0:
|
||
return found
|
||
for ref in _referenced_scripts(script_rel, text):
|
||
for name, src in _collect_third_party(ref, depth - 1, seen).items():
|
||
found.setdefault(name, src)
|
||
return found
|
||
|
||
|
||
def _script_self_resolves(script_rel):
|
||
"""脚本是否自带解释器解析(先例:scripts/check_generated_fresh.py)。"""
|
||
path = REPO_ROOT / script_rel
|
||
if not path.is_file():
|
||
return False
|
||
text = path.read_text(encoding="utf-8", errors="replace")
|
||
return bool(re.search(r"DTU_[A-Z0-9_]*PYTHON|_resolve_python|_pick_python|_find_python|"
|
||
r"python_discovery|_global_python_candidates|Scripts[\/][^\n]{0,16}python\.exe|"
|
||
r"re-?exec|ensure_python", text))
|
||
|
||
|
||
def _missing_modules(python_path, modules):
|
||
"""用指定解释器一次性试导入这批模块,返回它缺的那些(解释器本身跑不起来返回 None)。"""
|
||
if not modules:
|
||
return []
|
||
code = "import importlib\nmiss = []\nfor m in %r:\n try:\n importlib.import_module(m)\n except Exception:\n miss.append(m)\nprint(','.join(miss))\n" % (sorted(modules),)
|
||
try:
|
||
cp = subprocess.run([python_path, "-c", code], capture_output=True, timeout=90)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return None
|
||
if cp.returncode != 0:
|
||
return None
|
||
tail = cp.stdout.decode("utf-8", "replace").strip().splitlines()
|
||
if not tail:
|
||
return []
|
||
return [x for x in tail[-1].split(",") if x]
|
||
|
||
|
||
def run_hook_preflight():
|
||
"""钩子解释器预检:报清"钩子跑在谁身上、裸 python 落到谁、缺哪些包"。
|
||
|
||
返回值:0 = 健康(含"确实缺包,但那些脚本自带解释器解析"这种合规形态);
|
||
2 = 环境/配置错误——已指名到钩子与包名,修完再跑门禁。
|
||
"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" preflight 钩子解释器预检(只读,不改环境)")
|
||
print("=" * 64)
|
||
|
||
hook = REPO_ROOT / ".git" / "hooks" / "pre-commit"
|
||
venv = str(REPO_ROOT / ".venv" / "Scripts" / "python.exe").lower()
|
||
if hook.is_file():
|
||
m = re.search(r"INSTALL_PYTHON='([^']+)'", hook.read_text(encoding="utf-8", errors="replace"))
|
||
if not m:
|
||
print("[WARN] .git/hooks/pre-commit 里读不到 INSTALL_PYTHON(pre-commit 版本变更?)")
|
||
elif m.group(1).lower() != venv:
|
||
print("[WARN] 钩子的 INSTALL_PYTHON = {}".format(m.group(1)))
|
||
print(" 而仓库 .venv = {} —— 两者不一致:提交期钩子跑的**不是**这个 .venv".format(venv))
|
||
print(" 先确认哪个才是你要的环境(详见 docs/环境与部署/本地工具路径环境变量方案.md §5)")
|
||
else:
|
||
print("[INFO] 提交期钩子跑在:{}(与仓库 .venv 一致)".format(m.group(1)))
|
||
else:
|
||
print("[INFO] 本工作树没有 .git/hooks/pre-commit(worktree 或未安装钩子),跳过一致性核对")
|
||
|
||
path_first = shutil.which("python")
|
||
if not path_first:
|
||
print("[WARN] PATH 上没有 python —— language: system 的裸 python 条目会直接找不到解释器")
|
||
return 2
|
||
try:
|
||
ver = subprocess.run([path_first, "-c", "import sys;print(sys.version.split()[0])"],
|
||
capture_output=True, timeout=60).stdout.decode("utf-8", "replace").strip()
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
ver = "?"
|
||
entries = _hook_bare_python_entries()
|
||
need_all = set()
|
||
rows = []
|
||
lazy_all = {}
|
||
for hook_id, script in entries:
|
||
forced, hinted, lazy = _script_third_party(script)
|
||
if forced or hinted:
|
||
need_all.update(forced.keys())
|
||
for name, src in lazy.items():
|
||
lazy_all.setdefault(name, []).append(hook_id)
|
||
rows.append((hook_id, script, forced, hinted,
|
||
_script_self_resolves(script) if (forced or hinted) else True))
|
||
missing = _missing_modules(path_first, need_all)
|
||
if missing is None:
|
||
print("[ERROR] 探测解释器失败:{}(跑不起来,预检无法完成)".format(path_first))
|
||
return 2
|
||
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
|
||
len(entries), path_first, ver))
|
||
print("[INFO] 这些条目累计需要的第三方包:{};该解释器缺:{}".format(
|
||
", ".join(sorted(need_all)) or "(无,全是纯标准库)", ", ".join(sorted(missing)) or "(无)"))
|
||
bad = [(h, s, f) for (h, s, f, _h2, resolved) in rows
|
||
if f and not resolved and any(x in missing for x in f)]
|
||
for hook_id, script, forced, hinted, resolved in rows:
|
||
if not forced and not hinted:
|
||
continue
|
||
names = sorted(set(list(forced) + list(hinted)))
|
||
lack = [x for x in names if x in missing]
|
||
tag = "OK " if (not lack or resolved or not forced) else "FAIL"
|
||
if lack and hinted and not forced:
|
||
tag = "WARN"
|
||
via = sorted({src for name, src in hinted.items() if src and src != script})
|
||
print(" [{}] {} <- {} 需 {}{};脚本自带解释器解析:{}{}".format(
|
||
tag, hook_id, script, ", ".join(names),
|
||
"(该解释器缺 " + ", ".join(lack) + ")" if lack else "", "是" if resolved else "否",
|
||
";其中下游引入:" + ", ".join(via) if via else ""))
|
||
if bad:
|
||
print("")
|
||
print("[ERROR] 以下钩子会**在跑到它时**才崩(No module named ...),现在提前拦下:")
|
||
for hook_id, script, mods in bad:
|
||
print(" - {}:{} 需要 {},而裸 python({})没装".format(hook_id, script, ", ".join(mods), path_first))
|
||
print(" 三选一(别改 requirements.txt 的版本约束,那是别人环境的输入):")
|
||
print(" 1) 把 entry 改成能自解析解释器的写法,或在脚本内按「"
|
||
"显式 var > 当前解释器 > 仓库 .venv > PATH」解析 + 探针校验(先例 scripts/check_generated_fresh.py);")
|
||
print(" 2) 把 DTU_PRECOMMIT_PYTHON 显式指向装有这些包的解释器(不会改变裸 python 条目的落点,"
|
||
"但能让 pre-commit 本体确定);")
|
||
print(" 3) 在该解释器里装缺的包——动环境前必须拿到静默窗口,"
|
||
"顺序与回滚见 docs/环境与部署/本地工具路径环境变量方案.md §5.3。")
|
||
return 2
|
||
if lazy_all:
|
||
print("[INFO] 候选(下游惰性 / 条件导入的第三方包,静态判不了会不会被走到,故不作判决):")
|
||
for name in sorted(lazy_all):
|
||
probe = "" if name not in missing else "(裸 python 那个解释器**确实没装**)"
|
||
print(" - {}:{}{}".format(name, ", ".join(sorted(set(lazy_all[name]))[:6]), probe))
|
||
print("[OK] 预检通过:没有「裸 python + 缺包 + 不自解析」三事齐备的钩子")
|
||
return 0
|
||
|
||
|
||
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)。
|
||
|
||
返回 (退出码, 全部输出文本)。用于主机侧纯逻辑单测解析各模块汇总行。
|
||
"""
|
||
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_static_gates():
|
||
"""静态门禁:pre-commit 全部钩子。返回退出码(0/1/2)。"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" static 静态门禁(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] static: pre-commit hooks -- passed={} failed={} skipped={}".format(
|
||
passed, failed, skipd))
|
||
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 钩子。
|
||
|
||
与 CI 的 scripts/ci_gitlint_range.ps1 同语义(gitlint 声明
|
||
`stages: [commit-msg]`,`pre-commit run --all-files` 永远跑不到它)。
|
||
返回退出码(0/1/2)。
|
||
"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" commitmsg 提交信息门禁(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():
|
||
"""体积门禁(强制路径)。返回退出码(0/1/2)。
|
||
|
||
为什么必须单列一步(2026-10-25):`mem-numbers` / `mem-budget` 的事实源是 Keil
|
||
`.map`,无 `.map` 时两者**自适应 SKIP 并返回 0**——"改了代码但根本没编译"
|
||
这条最常见的漏测路径因此在本地也是绿的,CI 更是整根钩子都不跑。它要拦的正是
|
||
这个**"没跑 = 通过"**的盲区。
|
||
|
||
本步跑 `scripts/check_size_gate.py --local`,不需要 Keil:它校验**体积同码凭证**
|
||
(`scripts/size_attestation.json`,只能由"新鲜 .map + 未超 92% 线"的测量写出),
|
||
于是"代码变了但体积从没测过"在这里必然拦红,并给出可照做的修复命令。
|
||
CI 的「体积门禁」步用的是同一条实现,两边不会各说一套口径。
|
||
"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" size 体积门禁(同码凭证 + 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_host_unit_tests(args):
|
||
"""主机侧纯逻辑单测。返回退出码(0/1/2)。"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" host-unit 主机侧纯逻辑单测(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] host-unit: {} runner(s), {}/{} assertions passed, fail={}".format(
|
||
runner_count, sum_pass, sum_total, rc_all))
|
||
return rc_all
|
||
|
||
|
||
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 等价的门禁(静态门禁 + 主机侧纯逻辑单测,纯标准库)")
|
||
g = ap.add_mutually_exclusive_group()
|
||
g.add_argument("--static-only", "--l0-only", dest="static_only", action="store_true", help="只跑静态门禁(旧旗标 --l0-only 仍可用)")
|
||
g.add_argument("--host-unit-only", "--l05-only", dest="host_unit_only", action="store_true", help="只跑主机侧纯逻辑单测(旧旗标 --l05-only 仍可用)")
|
||
g.add_argument("--size-only", action="store_true",
|
||
help="只跑体积门禁(强制路径;约 0.3 s,无需 Keil)")
|
||
ap.add_argument("--coverage", action="store_true",
|
||
help="[已并入 manual 段] 主机单测覆盖率现由 test-coverage 钩子在 manual 段跑,"
|
||
"本旗标保留只为兼容旧命令(不再额外跑一遍)")
|
||
ap.add_argument("--preflight-only", action="store_true",
|
||
help="只跑钩子解释器预检(只读;缺包时当场指名钩子)")
|
||
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
|
||
|
||
# 钩子解释器预检(preflight):把"裸 python 条目 + 缺第三方包 + 脚本不自解析"这条隐性依赖
|
||
# 提到所有钩子之前判掉(道G task-33)。--preflight-only 时它是唯一一段。
|
||
rc_pre = run_hook_preflight()
|
||
if args.preflight_only:
|
||
return rc_pre
|
||
if rc_pre == 2:
|
||
print("")
|
||
print("[FAIL] 钩子解释器预检未过——先按上面指名的钩子/包修配置或环境,再跑门禁")
|
||
if not args.keep_going:
|
||
return 2
|
||
|
||
rc = 0
|
||
# 实际跑过哪些段——收尾那行只报这些。理由与那个盲区同源且是同一类错误:
|
||
# "--host-unit-only 却宣布静态门禁/提交信息门禁/体积门禁全部通过"就是把没跑的说成跑过了(SKIP 冒充 PASS
|
||
# 的镜像版)。2026-10-25 做 task-10 时从 --host-unit-only 的输出里发现,当场改掉。
|
||
ran = ["preflight"] # 钩子解释器预检已在本脚本开头无条件跑过,收尾行照实列出
|
||
try:
|
||
if args.size_only:
|
||
return run_size_gate()
|
||
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)
|
||
ran.append("commitmsg")
|
||
# 体积门禁同样**无条件照跑**:它回答的是"这份代码测过体积没有",
|
||
# 与静态门禁/提交信息门禁的结论正交,且只要 0.3 s——少跑一次就是那个盲区。
|
||
rc_size = run_size_gate()
|
||
ran.append("size")
|
||
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_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:
|
||
print("")
|
||
print("[INFO] --coverage 已并入 manual 段(test-coverage 钩子,与 CI 同口径),"
|
||
"不再重复跑插桩;该旗标保留只为兼容旧命令。")
|
||
|
||
except FileNotFoundError as e:
|
||
print("[ERROR] 环境错误:{}".format(e))
|
||
return 2
|
||
|
||
print("")
|
||
print("=" * 64)
|
||
if rc == 0:
|
||
print(" [OK] 本地 CI 全部通过({})".format(" + ".join(ran)))
|
||
else:
|
||
print(" [FAIL] 本地 CI 有未通过项(见上方输出)")
|
||
print("=" * 64)
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|