pbl_blueprint/scripts/selfcheck.py
2026-09-16 12:49:06 +08:00

526 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""pbl_blueprint M1a 自查门禁(重写版,退出码真实反映结果)。
设计原则
--------
1. 只核验「真实落盘文件」,逐项输出 PASS/FAIL + 证据,禁止凭摘要放行。
2. 有任一 FAIL 或检查组异常 -> exit 1门禁有效全 PASS -> exit 0。
3. 判定规则严格对齐规范,修正旧版三处自身缺陷:
- CRUD 契约的 editable 读「根级数组」(旧版误读 params 内字段级 bool恒判空
- 根键要求「必须包含 tblname+params」并允许规范要求的 editable/browserfields
(旧版要求根键严格等于 2 键,与 QC 要求 editable/browserfields 存在自相矛盾)
- id str32 判定为 type=str 且 size=32规范表达不再要求字面量 "str32"
4. DDL 覆盖按设计定稿判定核心表独立建表node/edge/field/rel 等子对象由
泛化单表 pbl_subobject 承载obj_type 判别 + payload JSON故不做 11 表
逐一 CREATE TABLE 硬判,改为核验「主表+版本+delta+泛化+锁+fork+模板+审计」
实际建表清单并输出证据。
用法
----
python3 modules/pbl_blueprint/scripts/selfcheck.py
python3 modules/pbl_blueprint/scripts/selfcheck.py --report <输出报告路径>
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR) # modules/pbl_blueprint
PKG_DIR = os.path.join(REPO_ROOT, "pbl_blueprint") # 包目录
MODELS_DIR = os.path.join(PKG_DIR, "models")
JSON_DIR = os.path.join(PKG_DIR, "json")
SQL_DIR = os.path.join(PKG_DIR, "sql")
SCRIPTS_DIR = os.path.join(REPO_ROOT, "scripts")
TESTS_DIR = os.path.join(REPO_ROOT, "tests")
SKILL_MD = os.path.join(REPO_ROOT, "skill", "SKILL.md")
LOAD_PATH = os.path.join(SCRIPTS_DIR, "load_path.py")
PYPROJECT = os.path.join(REPO_ROOT, "pyproject.toml")
# 泛化承载:这些 models 表在物理 DDL 中由 pbl_subobject / pbl_subobject_rel 承载
GENERIC_CARRIER = {
"pbl_blueprint_node": "pbl_subobject",
"pbl_blueprint_edge": "pbl_subobject_rel",
}
ERRORS_14 = [
"PblBlueprintError", "ERR_OK", "ERR_TENANT_MISSING", "ERR_PARAM_INVALID",
"ERR_NOT_FOUND", "ERR_DUPLICATE", "ERR_STATE_INVALID", "ERR_LOCKED",
"ERR_FORBIDDEN", "ERR_DB", "ERR_INTERNAL", "ok", "fail", "err",
]
SQLOR_WHITELIST = {"C", "U", "D", "R", "I", "sqlExe"}
LINES: list[str] = []
N_PASS = 0
N_FAIL = 0
def record(group: str, ok: bool, item: str, evidence: str = "") -> bool:
"""记录一条核验结果。返回 ok。"""
global N_PASS, N_FAIL
tag = "[PASS]" if ok else "[FAIL]"
if ok:
N_PASS += 1
else:
N_FAIL += 1
line = "%s %s | %s" % (tag, group, item)
if evidence:
line += " -> %s" % evidence
LINES.append(line)
print(line)
return ok
def check(group: str, ok: bool, item: str, evidence: str = "") -> bool:
return record(group, bool(ok), item, evidence)
def _num(v, default=0):
"""容错取数size 可能是 int / 数字串 / [18,2] 列表(取首元素)。"""
if isinstance(v, (list, tuple)):
v = v[0] if v else default
if isinstance(v, bool):
return default
if isinstance(v, (int, float)):
return int(v)
if isinstance(v, str):
m = re.search(r"\d+", v)
return int(m.group()) if m else default
return default
def read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def load_json(path: str):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f), None
except Exception as e: # noqa: BLE001
return None, str(e)
def py_files(exclude_dirs=()):
out = []
for base, dirs, files in os.walk(REPO_ROOT):
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__")]
rel = os.path.relpath(base, REPO_ROOT)
if any(rel == d or rel.startswith(d + os.sep) for d in exclude_dirs):
continue
for fn in files:
if fn.endswith(".py"):
out.append(os.path.join(base, fn))
return sorted(out)
def rel(path: str) -> str:
return os.path.relpath(path, REPO_ROOT).replace(os.sep, "/")
# ============================================================== A. 目录结构
def group_a() -> None:
g = "A.目录结构"
check(g, os.path.isdir(PKG_DIR), "包目录 modules/pbl_blueprint/pbl_blueprint/ 存在",
rel(PKG_DIR))
check(g, os.path.isfile(os.path.join(PKG_DIR, "__init__.py")),
"包 __init__.py 存在", rel(os.path.join(PKG_DIR, "__init__.py")))
check(g, os.path.isfile(os.path.join(PKG_DIR, "init.py")),
"挂载入口 init.py 存在", rel(os.path.join(PKG_DIR, "init.py")))
check(g, os.path.isfile(PYPROJECT), "pyproject.toml 存在", rel(PYPROJECT))
check(g, os.path.isfile(LOAD_PATH), "scripts/load_path.py 存在", rel(LOAD_PATH))
check(g, os.path.isfile(SKILL_MD), "skill/SKILL.md 存在", rel(SKILL_MD))
check(g, os.path.isdir(MODELS_DIR), "models/ 表定义目录存在", rel(MODELS_DIR))
check(g, os.path.isdir(JSON_DIR), "json/ CRUD 契约目录存在", rel(JSON_DIR))
# 违禁替代文件module.json / conf/rp.json / 模块级 app.py / build.sh
bad = []
for name in ("module.json", "app.py", "build.sh", "Dockerfile"):
if os.path.isfile(os.path.join(REPO_ROOT, name)):
bad.append(name)
if os.path.isfile(os.path.join(REPO_ROOT, "conf", "rp.json")):
bad.append("conf/rp.json")
check(g, not bad, "无违禁替代文件module.json/app.py/build.sh/Dockerfile/conf/rp.json",
"命中=%s" % (bad or ""))
# ============================================================== B. 表定义四段式
def group_b() -> None:
g = "B.表定义"
files = sorted(f for f in os.listdir(MODELS_DIR) if f.endswith(".json")) \
if os.path.isdir(MODELS_DIR) else []
check(g, len(files) == 11, "models/*.json 表定义数 = 11", "实际=%d %s" % (len(files), files))
for fn in files:
tbl = fn[:-5]
data, errx = load_json(os.path.join(MODELS_DIR, fn))
if data is None:
record(g, False, "%s JSON 可解析" % tbl, "解析失败: %s" % errx)
continue
# 四段式
segs = ["summary", "fields", "indexes", "codes"]
miss = [s for s in segs if s not in data]
check(g, not miss, "%s 四段式 summary/fields/indexes/codes 齐备" % tbl,
"缺失=%s" % (miss or ""))
fields = data.get("fields") or {}
# primary = ["id"]
prim = data.get("primary")
check(g, prim == ["id"], "%s primary=[\"id\"]" % tbl, "实际=%s" % (prim,))
# id str32
idf = fields.get("id") or {}
id_ok = (idf.get("type") == "str" and _num(idf.get("size")) == 32)
check(g, id_ok, "%s id 为 str32" % tbl,
"type=%s size=%s" % (idf.get("type"), idf.get("size")))
# tenant_id 为首业务字段
keys = [k for k in fields.keys() if k != "id"]
check(g, bool(keys) and keys[0] == "tenant_id",
"%s tenant_id 为首业务字段" % tbl, "首业务字段=%s" % (keys[0] if keys else None))
tf = fields.get("tenant_id") or {}
check(g, tf.get("type") == "str" and _num(tf.get("size")) == 32 and tf.get("notnull") is True,
"%s tenant_id str32 notnull" % tbl,
"type=%s size=%s notnull=%s" % (tf.get("type"), tf.get("size"), tf.get("notnull")))
# 金额字段 double(18,2)
money = [k for k, v in fields.items()
if isinstance(v, dict) and re.search(r"(amount|price|fee|money|cost|score)$", k)]
bad_money = []
for k in money:
v = fields[k]
sz = v.get("size")
sc_ = v.get("scale", v.get("decimal"))
# double(18,2) 的三种合法写法size=[18,2] / size=18+scale=2 / precision=18+scale=2
ok_pair = (isinstance(sz, (list, tuple)) and len(sz) == 2
and _num(sz[0]) == 18 and _num(sz[1]) == 2)
ok_split = (_num(sz) == 18 and _num(sc_) == 2)
ok_prec = (_num(v.get("precision")) == 18 and _num(sc_) == 2)
if not (v.get("type") == "double" and (ok_pair or ok_split or ok_prec)):
bad_money.append("%s(type=%s,size=%s,scale=%s)" % (k, v.get("type"), sz, sc_))
check(g, not bad_money, "%s 金额字段 double(18,2)" % tbl,
"金额字段=%s 不合规=%s" % (money or "", bad_money or ""))
# ============================================================== C. CRUD 契约
def group_c() -> None:
g = "C.CRUD契约"
files = sorted(f for f in os.listdir(JSON_DIR) if f.endswith(".json")) \
if os.path.isdir(JSON_DIR) else []
check(g, len(files) == 11, "json/*.json CRUD 契约数 = 11", "实际=%d" % len(files))
for fn in files:
tbl = fn[:-5]
data, errx = load_json(os.path.join(JSON_DIR, fn))
if data is None:
record(g, False, "%s JSON 可解析" % tbl, "解析失败: %s" % errx)
continue
keys = list(data.keys())
# 根键必须包含 tblname + paramseditable/browserfields 为规范要求的附加键
check(g, "tblname" in keys and "params" in keys,
"%s 根键含 tblname+params" % tbl, "根键=%s" % keys)
extra = [k for k in keys if k not in ("tblname", "params", "editable", "browserfields")]
check(g, not extra, "%s 无自创 table/list 等格式" % tbl, "多余根键=%s" % (extra or ""))
check(g, data.get("tblname") == tbl, "%s tblname 与文件名一致" % tbl,
"tblname=%s" % data.get("tblname"))
ed = data.get("editable")
ed_ok = (isinstance(ed, list) and len(ed) == 3
and all(isinstance(x, str) and x.endswith(".dspy") for x in ed))
check(g, ed_ok, "%s editable = 3 个 .dspy URL" % tbl, "editable=%s" % (ed,))
bf = data.get("browserfields")
check(g, isinstance(bf, list) and len(bf) > 0, "%s browserfields 非空" % tbl,
"browserfields=%s" % (bf,))
params = data.get("params") or {}
check(g, isinstance(params, dict) and len(params) > 0,
"%s params 非空字段字典" % tbl, "字段数=%d" % len(params))
pk = [k for k in params.keys() if k != "id"]
check(g, bool(pk) and pk[0] == "tenant_id",
"%s params 首业务字段为 tenant_id" % tbl, "首业务字段=%s" % (pk[0] if pk else None))
# ============================================================== D. 代码质量
def group_d() -> None:
g = "D.代码质量"
all_py = py_files()
biz_py = py_files(exclude_dirs=("scripts", "tests"))
check(g, len(biz_py) > 0, "存在业务 .py 源文件",
"业务=%d 全量=%d" % (len(biz_py), len(all_py)))
# py_compile 全量
r = subprocess.run([sys.executable, "-m", "py_compile"] + all_py,
capture_output=True, text=True)
check(g, r.returncode == 0, "py_compile 全量编译(%d 文件)" % len(all_py),
"rc=%d %s" % (r.returncode, (r.stderr or "").strip()[-300:]))
# 禁硬编码库名(仅业务代码;自查脚本的模式串与测试桩形参不算业务硬编码)
pat = re.compile(r"(DBNAME\s*=\s*['\"][^'\"]+['\"]|dbname\s*=\s*['\"][^'\"]+['\"]|DB_NAME\s*=\s*['\"][^'\"]+['\"])")
hits = []
for p in biz_py:
for i, ln in enumerate(read(p).split("\n"), 1):
if pat.search(ln):
hits.append("%s:%d" % (rel(p), i))
check(g, not hits, "禁硬编码库名(业务代码,排除 scripts/tests",
"命中=%s" % (hits or ""))
# 取库名方式
init_src = read(os.path.join(PKG_DIR, "init.py"))
check(g, "get_module_dbname" in init_src,
"init.py 通过 ServerEnv().get_module_dbname 取库名",
"命中 get_module_dbname=%s" % ("get_module_dbname" in init_src))
# sqlor API 白名单
used = set()
viol = []
api_pat = re.compile(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
for p in biz_py:
for i, ln in enumerate(read(p).split("\n"), 1):
for m in api_pat.finditer(ln):
name = m.group(1)
used.add(name)
if name not in SQLOR_WHITELIST:
viol.append("%s:%d sor.%s" % (rel(p), i, name))
check(g, not viol, "sqlor API 白名单(仅 C/U/D/R/I/sqlExe",
"实际使用=%s 越界=%s" % (sorted(used) or "", viol or ""))
# 禁编造 ORM 风格 API
fake = []
fake_pat = re.compile(r"\bsor\.(save|list|insert|update_one|delete_one|find|query|get)\s*\(")
for p in biz_py:
for i, ln in enumerate(read(p).split("\n"), 1):
if fake_pat.search(ln):
fake.append("%s:%d" % (rel(p), i))
check(g, not fake, "无编造 sqlor APIsave/list/insert/find…", "命中=%s" % (fake or ""))
# ============================================================== E. 注册同步
def group_e() -> None:
g = "E.注册同步"
pkg_init = read(os.path.join(PKG_DIR, "__init__.py"))
miss = [s for s in ERRORS_14 if not re.search(r"\b%s\b" % re.escape(s), pkg_init)]
check(g, not miss, "__init__.py 导出 errors 14 符号", "缺失=%s" % (miss or ""))
check(g, os.path.isfile(os.path.join(PKG_DIR, "errors.py")),
"errors.py 存在14 符号定义处)", rel(os.path.join(PKG_DIR, "errors.py")))
err_src = read(os.path.join(PKG_DIR, "errors.py"))
miss2 = [s for s in ERRORS_14 if not re.search(r"^(class|def)\s+%s\b|^%s\s*=" % (re.escape(s), re.escape(s)), err_src, re.M)]
check(g, not miss2, "errors.py 定义 14 符号", "缺失=%s" % (miss2 or ""))
init_src = read(os.path.join(PKG_DIR, "init.py"))
check(g, "def load_pbl_blueprint(" in init_src,
"init.py 定义 load_pbl_blueprint()", "入口存在=%s" % ("def load_pbl_blueprint(" in init_src))
reg_pats = [
r"env\.[A-Za-z_][A-Za-z0-9_]*\s*=", # env.func = handler
r"setattr\s*\(\s*env", # setattr(env, name, fn)
r"env\[[^\]]+\]\s*=", # env['func'] = handler
r"register[A-Za-z_]*\s*\(", # register_func(...) / env.register(...)
r"env\.(?:register|add|set)[A-Za-z_]*\s*\(",
r"\bapi\s*=\s*\{", # 返回 api 字典由框架注册
r"\bfuncs\s*=\s*\{",
r"ServerEnv\s*\(",
]
reg = []
for pat in reg_pats:
reg += re.findall(pat, init_src)
check(g, len(reg) > 0, "init.py 向 ServerEnv 注册契约函数/挂载 api",
"注册命中数=%d 样例=%s" % (len(reg), sorted(set(reg))[:5]))
check(g, "load_pbl_blueprint" in pkg_init,
"__init__.py 重导出 load_pbl_blueprint三处同步",
"命中=%s" % ("load_pbl_blueprint" in pkg_init))
# ============================================================== F. RBAC 路径
def group_f() -> None:
"""以 import load_path 后的运行时 PATHS 元组为权威(不扫源码文本,避免把
生成器模板串 '/api/x/%s.dspy' 误判为通配路径)。"""
g = "F.RBAC路径"
check(g, os.path.isfile(LOAD_PATH), "load_path.py 存在", rel(LOAD_PATH))
mod = None
err = ""
try:
import importlib.util
spec = importlib.util.spec_from_file_location("_lp_m1a", LOAD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e: # noqa: BLE001
err = "%s: %s" % (type(e).__name__, e)
check(g, mod is not None, "load_path.py 可导入执行", err or "导入成功")
if mod is None:
return
paths = list(getattr(mod, "PATHS", ()) or ())
check(g, len(paths) > 0, "RBAC 注册路径显式枚举PATHS 元组)",
"路径数=%d" % len(paths))
wild = [p for p in paths if "%" in p or "*" in p]
check(g, not wild, "RBAC 路径无 %/* 通配符(运行时真实值)",
"通配路径=%s" % (wild[:5] or ""))
nonstd = [p for p in paths
if not re.fullmatch(r"/api/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\.dspy", p)]
check(g, not nonstd, "RBAC 路径均为 /api/<表>/<动作>.dspy 规范字面量",
"异常=%s" % (nonstd[:5] or ""))
dup = sorted({p for p in paths if paths.count(p) > 1})
check(g, not dup, "RBAC 路径无重复", "重复=%s" % (dup[:5] or ""))
# 覆盖 json/*.json editable 的 3×11 条(超集合法:支撑表含额外动作)
need = set()
if os.path.isdir(JSON_DIR):
for fn in sorted(os.listdir(JSON_DIR)):
if not fn.endswith(".json"):
continue
d, _e = load_json(os.path.join(JSON_DIR, fn))
for u in ((d or {}).get("editable") or []):
if isinstance(u, str) and u.startswith("api/"):
need.add("/" + u)
missing = sorted(need - set(paths))
check(g, not missing,
"load_path.py 覆盖 json/*.json editable 全部路径(超集)",
"load_path=%d json_editable=%d 未覆盖=%s"
% (len(paths), len(need), missing[:5] or ""))
# 孤儿路径:每条路径的表名必须归属主清单 11 表或支撑表 9 表
main_t = set(getattr(mod, "MAIN_TABLES", ()) or ())
sup_t = set(getattr(mod, "SUPPORT_TABLES", ()) or ())
known = main_t | sup_t
check(g, len(main_t) == 11, "load_path.MAIN_TABLES = 设计定稿 11 表",
"实际=%d" % len(main_t))
orphan = []
for p in paths:
m = re.match(r"/api/([a-z0-9_]+)/", p)
if not m or (known and m.group(1) not in known):
orphan.append(p)
check(g, not orphan, "load_path.py 无孤儿路径(均归属已知表)",
"孤儿=%s" % (orphan[:5] or ""))
# 模块自带 validate() 必须通过
vfn = getattr(mod, "validate", None)
if callable(vfn):
verrs = vfn() or []
check(g, not verrs, "load_path.validate() 自检通过",
"错误=%s" % (verrs[:3] or ""))
else:
check(g, False, "load_path.validate() 存在", "未定义 validate()")
# ============================================================== G. 租户 fail-closed
def group_g() -> None:
g = "G.租户fail-closed"
biz = py_files(exclude_dirs=("scripts", "tests"))
src_all = "\n".join(read(p) for p in biz)
check(g, "ERR_TENANT_MISSING" in src_all,
"业务代码引用 ERR_TENANT_MISSING缺租户即拒",
"命中=%s" % ("ERR_TENANT_MISSING" in src_all))
check(g, os.path.isfile(os.path.join(PKG_DIR, "tenant.py")),
"tenant.py 租户上下文模块存在", rel(os.path.join(PKG_DIR, "tenant.py")))
tsrc = read(os.path.join(PKG_DIR, "tenant.py"))
check(g, "tenant_id" in tsrc, "tenant.py 处理 tenant_id", "命中=%s" % ("tenant_id" in tsrc))
# 禁止默认租户兜底
bad = re.findall(r"tenant_id\s*=\s*['\"](?:default|DEFAULT|1|0|test)['\"]", src_all)
check(g, not bad, "无默认租户兜底fail-closed", "命中=%s" % (bad or ""))
# ============================================================== H. DDL
def group_h() -> None:
g = "H.DDL"
sqls = sorted(f for f in os.listdir(SQL_DIR) if f.endswith(".sql")) if os.path.isdir(SQL_DIR) else []
check(g, len(sqls) >= 2, "sql/ DDL 文件存在core + subobjects", "文件=%s" % sqls)
ddl = "\n".join(read(os.path.join(SQL_DIR, f)) for f in sqls)
tables = sorted(set(re.findall(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-z0-9_]+)`?", ddl, re.I)))
check(g, len(tables) > 0, "DDL 含 CREATE TABLE 语句", "建表数=%d" % len(tables))
# 设计定稿 B1~B3 核心表B4~B11 为独立建表,非泛化单表)
need_core = ["pbl_blueprint", "pbl_blueprint_version", "pbl_blueprint_approval"]
miss = [t for t in need_core if t not in tables]
check(g, not miss, "DDL 覆盖核心表 B1~B3聚合根/版本/审批)",
"缺失=%s" % (miss or ""))
# 设计定稿 B4~B11 八张子对象表必须独立建表
need_sub = ["pbl_learner", "pbl_learning_goal", "pbl_problem",
"pbl_driving_question", "pbl_project", "pbl_role",
"pbl_mission", "pbl_artifact_def"]
miss_sub = [t for t in need_sub if t not in tables]
check(g, not miss_sub, "DDL 覆盖子对象表 B4~B118 张独立建表)",
"缺失=%s 建表总数=%d" % (miss_sub or "", len(tables)))
# models 表 -> 物理承载映射核验
mfiles = sorted(f[:-5] for f in os.listdir(MODELS_DIR) if f.endswith(".json")) \
if os.path.isdir(MODELS_DIR) else []
# 设计定稿models 主清单 11 表 = DDL 独立建表,逐表必须有 CREATE TABLE
uncovered = [t for t in mfiles if t not in tables]
check(g, not uncovered, "models 主清单 11 表均有独立 CREATE TABLE 承载",
"未承载=%s models=%d ddl建表=%d" % (uncovered or "", len(mfiles), len(tables)))
check(g, "tenant_id" in ddl, "DDL 各表含 tenant_id 列", "命中=%s" % ("tenant_id" in ddl))
# ============================================================== I. git 证据
def group_i() -> None:
g = "I.git证据"
def git(*args):
r = subprocess.run(["git", "-C", REPO_ROOT] + list(args),
capture_output=True, text=True)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
rc, out, _ = git("rev-parse", "--is-inside-work-tree")
check(g, rc == 0 and out == "true", "模块目录为 git 仓库", "rc=%d out=%s" % (rc, out))
rc, head, _ = git("log", "-1", "--format=%h %s")
check(g, rc == 0 and bool(head), "存在本地提交HEAD", head[:120])
rc, st, _ = git("status", "--porcelain")
dirty = [x for x in st.split("\n") if x.strip()]
check(g, not dirty, "工作区 clean变更已全部提交",
"未提交条目=%d %s" % (len(dirty), dirty[:5]))
rc, rem, _ = git("remote", "-v")
check(g, "origin" in rem, "配置 origin 远程", rem.split("\n")[0][:100] if rem else "")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--report", default="", help="报告输出路径")
args = ap.parse_args()
groups = [
("A.目录结构", group_a), ("B.表定义", group_b), ("C.CRUD契约", group_c),
("D.代码质量", group_d), ("E.注册同步", group_e), ("F.RBAC路径", group_f),
("G.租户fail-closed", group_g), ("H.DDL", group_h), ("I.git证据", group_i),
]
header = "=" * 78
LINES.append(header)
LINES.append("pbl_blueprint M1a 自查报告selfcheck.py 重写版)")
LINES.append("仓库根: %s" % REPO_ROOT)
LINES.append(header)
print("\n".join(LINES[-4:]))
for name, fn in groups:
LINES.append("-" * 78)
print("-" * 78)
try:
fn()
except Exception as e: # noqa: BLE001
import traceback
record(name, False, "检查组执行异常", "%s: %s" % (type(e).__name__, e))
LINES.append(traceback.format_exc())
LINES.append(header)
total = N_PASS + N_FAIL
verdict = "ALL PASS" if N_FAIL == 0 else "HAS FAILURES"
LINES.append("结论: %s | PASS=%d FAIL=%d 总=%d" % (verdict, N_PASS, N_FAIL, total))
LINES.append(header)
print("\n".join(LINES[-3:]))
if args.report:
try:
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as f:
f.write("\n".join(LINES) + "\n")
print("报告已写入: %s" % args.report)
except OSError as e:
print("报告写入失败: %s" % e)
# 门禁:有失败项必须非 0 退出
return 0 if N_FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())