2bfa09ed15
落地物: - scripts/check_test_coverage.py:发现规则与 L0.5 同一条(test/host/**/ run_unit_test.py),直接 import 各运行器复用其 TestBuild 与 unit_test_harness._BASE_FLAGS —— 配置零复制,不会漂出第二套口径; 只算自研生产源码(App/**、Drivers/BSP/**)的行/函数、多套取并集、 生成物(文件头写"自动生成/请勿手改")不进分母、分母一律来自插桩 报告不猜行数;中间物全在 %TEMP%,不新增 .gitignore 规则。 - scripts/test_coverage_baseline.json:增量拦截台账(只拦回退不拦进步, 与 layer-deps / bsp-err 同姿态);ci_local.py 加 opt-in --coverage。 - docs/测试说明/主机单测覆盖率口径.md:口径 + 工具链实测 + 九条盲区。 - 报告 §0.1 / §0.4 / §4.8 / §5.5 / §7 改写 + 变更记录 + 去处表。 工具链是实测出来的(换机必读):Windows 原生侧无任何 gcc/gcov(全盘扫 Program Files*、msys64、scoop、choco、.platformio、LocalAppData = 0 处); MSVC 14.51 已砍 /coverage,且只报 D9002 后照常编译成功 = 静默假数据; CodeCoverage.exe 17.0 三个子命令只打印用法。最终用 mscoverage 18.x (Microsoft.CodeCoverage.Console.exe)collect -f cobertura 做加载期动态 插桩,并实测它透传子进程退出码 → "插桩运行下仍必须 PASS"可当硬条件。 首登数字:16/16 套插桩下全绿,并集 line=2552/2893(88.2%)、 func=193/207(93.2%)、19 个文件在测、**未触及 125 个生产源文件 / 约 11,839 行**——最后一栏才是这套口径真正照出来的东西。 门禁自证:人为抬高基线后 行/函数/总计 三条回退路径都报 REGRESS 且 退出码 1;--module 子集跑不再假报总计回退(首版会,已修:并集口径下 子集必然变小,全部回退判定只在全量跑时生效)。 GCC 追问的复测结论(口径文档 §2.1 决策记录):本机 WSL2 Ubuntu 确实有 gcc/gcov 15.2.0,16 套单测在 gcc 下全部编过、用例数与 MSVC 逐个一致, gcov -b -c 也确实给出分支覆盖(nand_ftl.c 分支至少走过一次 41.40%); 但换编译器=换分母(sha256.c gcc 105 行 vs MSVC 114 行;adc_input.c 101 vs 120)。**用户裁定:本仓只留 MSVC 一条覆盖率通道**,不建第二条—— 多一套工具链就多一套分母、多一本台账、多一个"这台机器到底有没有 WSL/ gcov"的未决项,换来的只有分支这一维。分支覆盖因此**记为已接受缺口** (不是待办):绿灯只证明行与函数被测到,补偿判据 = TC 双向追溯 + 用例显式列边界值;§8 写明重开该裁定的前提是先答"两份分母怎么对齐"。 刻意未挂 pre-commit:16 个单测钩子已经跑过同一批测试;依赖 VS 可选 组件(缺组件时 skip 会静默失效、FAIL 会拦换机);待一个真实回退事件。 YAML 片段与理由写在口径文档 §8。 自查:pre-commit run --files(6 个改动文件)全 Passed;check_doc_links --all 回到存量 35 处(本分支贡献 0);check_ids.py --strict PASS; check_mojibake + BOM/CR/U+FFFD/行尾卫生逐项自查全净。本轮零 .c/.h 改动 (纯 .py/.json/.md),故未跑 @build、未占 build/bench 锁。
438 lines
19 KiB
Python
438 lines
19 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)
|
||
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())
|