pbl_blueprint/scripts/selfcheck.py

571 lines
25 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_blueprint M1a 自查门禁脚本。
用法(机构工作空间根执行)::
python3 modules/pbl_blueprint/scripts/selfcheck.py
echo "rc=$?"
退出码约定(**本轮修复项 B**
0 —— 全部检查通过(或仅 SKIP无 FAIL
1 —— 存在 FAIL 项门禁不通过QC/CI 据此判定)
2 —— 脚本自身无法运行(仓库结构缺失等致命错误)
检查分组:
A. 目录结构 —— 包目录/init.py/pyproject.toml/scripts/skill 真实存在
B. 表定义 —— models/*.json 11 个四段式齐备、primary=["id"]、id str32、
tenant_id 为首业务字段、金额 double(18,2)
C. CRUD 契约 —— json/*.json 11 个,根键 tblname+params、editable 三个 .dspy URL、
browserfields 非空、无自创 table/list 格式
D. 代码质量 —— py_compile 全量、禁硬编码库名、sqlor API 白名单
E. 注册同步 —— 函数三处注册(定义 / __init__ 导出 / init.py env 注册)
F. 契约运行 —— import pbl_blueprint 与 errors.py 14 符号
G. 租户 fail-closed —— 缺 tenant_id 一律拒绝
H. DDL 生成 —— sql/*.sql 与 models 表集合一致
本脚本**不依赖** sqlor/ahserver/sage 运行时;缺失运行时依赖的项按 SKIP 处理并标注,
不计入 FAIL避免 CI 环境假失败),但 SKIP 数会在结论中明示。
"""
from __future__ import print_function
import json
import os
import re
import subprocess
import sys
# --------------------------------------------------------------------------
# 路径解析:脚本位于 modules/pbl_blueprint/scripts/,仓库根 = 上一级
# --------------------------------------------------------------------------
HERE = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(HERE) # modules/pbl_blueprint
PKG_DIR = os.path.join(REPO_ROOT, "pbl_blueprint") # modules/pbl_blueprint/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")
EXPECTED_TABLES = [
"pbl_blueprint",
"pbl_blueprint_node",
"pbl_blueprint_edge",
"pbl_blueprint_version",
"pbl_blueprint_version_delta",
"pbl_blueprint_template",
"pbl_blueprint_fork",
"pbl_blueprint_lock",
"pbl_blueprint_publish",
"pbl_blueprint_offline",
"pbl_blueprint_audit",
]
ERROR_SYMBOLS = [
"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"}
HARDCODE_DB_PATTERNS = [r"\bDBNAME\s*=", r"dbname\s*=\s*['\"]", r"\bDB_NAME\s*="]
# --------------------------------------------------------------------------
# 结果收集
# --------------------------------------------------------------------------
RESULTS = [] # [(status, group, name, detail)]
def record(status, group, name, detail=""):
RESULTS.append((status, group, name, detail))
mark = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]"}.get(status, "[%s]" % status)
line = "%s %s | %s" % (mark, group, name)
if detail:
line += " -> %s" % detail
print(line)
def check(cond, group, name, detail_ok="", detail_bad=""):
record("PASS" if cond else "FAIL", group, name, detail_ok if cond else detail_bad)
return bool(cond)
def load_json(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# --------------------------------------------------------------------------
# A. 目录结构
# --------------------------------------------------------------------------
def group_a():
g = "A.目录结构"
required = [
(PKG_DIR, "包目录 pbl_blueprint/"),
(os.path.join(PKG_DIR, "__init__.py"), "pbl_blueprint/__init__.py"),
(os.path.join(PKG_DIR, "init.py"), "pbl_blueprint/init.py"),
(os.path.join(PKG_DIR, "errors.py"), "pbl_blueprint/errors.py"),
(os.path.join(REPO_ROOT, "pyproject.toml"), "pyproject.toml"),
(os.path.join(HERE, "load_path.py"), "scripts/load_path.py"),
(os.path.join(REPO_ROOT, "skill", "SKILL.md"), "skill/SKILL.md"),
(MODELS_DIR, "pbl_blueprint/models/"),
(JSON_DIR, "pbl_blueprint/json/"),
(SQL_DIR, "pbl_blueprint/sql/"),
]
for path, label in required:
check(os.path.exists(path), g, label,
"存在 %s" % path, "缺失 %s" % path)
# 违禁替代文件
forbidden = ["module.json", "rp.json"]
for name in forbidden:
bad = os.path.exists(os.path.join(REPO_ROOT, name)) or os.path.exists(os.path.join(PKG_DIR, name))
check(not bad, g, "无违禁替代文件 %s" % name, "未发现", "发现违禁文件 %s" % name)
# 模块不得有独立 app.py模块非部署单元
has_app = os.path.exists(os.path.join(REPO_ROOT, "app.py")) or os.path.exists(os.path.join(PKG_DIR, "app.py"))
check(not has_app, g, "模块无独立 app.py", "符合(模块非部署单元)", "发现 app.py模块不应独立部署")
# --------------------------------------------------------------------------
# B. 表定义models/*.json 四段式)
# --------------------------------------------------------------------------
def group_b():
g = "B.表定义"
if not os.path.isdir(MODELS_DIR):
record("FAIL", g, "models 目录", "缺失 %s" % MODELS_DIR)
return
files = sorted([f for f in os.listdir(MODELS_DIR) if f.endswith(".json")])
check(len(files) == len(EXPECTED_TABLES), g, "models/*.json 数量 = %d" % len(EXPECTED_TABLES),
"实际 %d" % len(files), "实际 %d,期望 %d" % (len(files), len(EXPECTED_TABLES)))
names = set(f[:-5] for f in files)
missing = [t for t in EXPECTED_TABLES if t not in names]
check(not missing, g, "表集合与设计定稿一致", "11 表齐备",
"缺失表: %s" % ",".join(missing))
for fn in files:
path = os.path.join(MODELS_DIR, fn)
tbl = fn[:-5]
try:
d = load_json(path)
except Exception as e: # noqa: BLE001
record("FAIL", g, "%s JSON 可解析" % tbl, "", "解析失败: %s" % e)
continue
# 四段式
segs = ["summary", "fields", "indexes", "codes"]
lack = [s for s in segs if s not in d]
check(not lack, g, "%s 四段式齐备" % tbl, "summary/fields/indexes/codes",
"缺段: %s" % ",".join(lack))
if lack:
continue
fields = d.get("fields") or {}
# primary = ["id"]
primary = d.get("primary")
if primary is None:
primary = (d.get("summary") or {}).get("primary")
check(primary == ["id"], g, "%s primary=[\"id\"]" % tbl, "primary=%s" % (primary,),
"primary=%s,期望 ['id']" % (primary,))
# id 为 str32
idf = fields.get("id") or {}
idt = str(idf.get("type", ""))
check(idt.startswith("str") and "32" in idt, g, "%s id 类型 str32" % tbl,
"id.type=%s" % idt, "id.type=%s,期望 str32" % idt)
# tenant_id 为首业务字段id 之后第一个)
keys = list(fields.keys())
biz = [k for k in keys if k != "id"]
check(bool(biz) and biz[0] == "tenant_id", g, "%s tenant_id 为首业务字段" % tbl,
"字段序: id, %s" % (biz[0] if biz else "-"),
"首业务字段=%s,期望 tenant_id" % (biz[0] if biz else "-"))
# 金额字段 double(18,2)
money_bad = []
for k, v in fields.items():
if not isinstance(v, dict):
continue
t = str(v.get("type", ""))
is_money_name = ("amount" in k) or ("money" in k) or ("price" in k) or ("fee" in k)
is_money_type = t.startswith("double") or t.startswith("decimal") or t.startswith("money")
if is_money_name or is_money_type:
if "18" not in t or "2" not in t:
money_bad.append("%s:%s" % (k, t))
if money_bad:
record("FAIL", g, "%s 金额字段 double(18,2)" % tbl, "", "不合规: %s" % ",".join(money_bad))
else:
record("PASS", g, "%s 金额字段 double(18,2)" % tbl, "无金额字段或均合规")
# indexes 中 tenant_id 打头(复合索引首列)
idx = d.get("indexes") or {}
bad_idx = []
if isinstance(idx, dict):
for ik, iv in idx.items():
cols = iv.get("fields") if isinstance(iv, dict) else iv
if isinstance(cols, str):
cols = [c.strip() for c in cols.split(",")]
if isinstance(cols, (list, tuple)) and cols and cols[0] != "tenant_id":
bad_idx.append("%s%s" % (ik, list(cols)))
if bad_idx:
record("FAIL", g, "%s 索引 tenant_id 打头" % tbl, "", "首列非 tenant_id: %s" % ";".join(bad_idx))
else:
record("PASS", g, "%s 索引 tenant_id 打头" % tbl, "全部复合索引首列为 tenant_id")
# --------------------------------------------------------------------------
# C. CRUD 契约json/*.json
# --------------------------------------------------------------------------
def group_c():
g = "C.CRUD契约"
if not os.path.isdir(JSON_DIR):
record("FAIL", g, "json 目录", "缺失 %s" % JSON_DIR)
return
files = sorted([f for f in os.listdir(JSON_DIR) if f.endswith(".json")])
check(len(files) == len(EXPECTED_TABLES), g, "json/*.json 数量 = %d" % len(EXPECTED_TABLES),
"实际 %d" % len(files), "实际 %d,期望 %d" % (len(files), len(EXPECTED_TABLES)))
for fn in files:
path = os.path.join(JSON_DIR, fn)
tbl = fn[:-5]
try:
d = load_json(path)
except Exception as e: # noqa: BLE001
record("FAIL", g, "%s JSON 可解析" % tbl, "", "解析失败: %s" % e)
continue
# 根键仅 tblname + params无自创 table/list 格式
root = set(d.keys())
check(root == {"tblname", "params"}, g, "%s 根键 = tblname+params" % tbl,
"根键=%s" % sorted(root), "根键=%s,期望 ['params','tblname']" % sorted(root))
check("table" not in root and "list" not in root, g, "%s 无自创 table/list 格式" % tbl,
"符合 crud-definition-spec", "发现自创键")
params = d.get("params") or {}
# editable 为三个 .dspy URL
ed = params.get("editable")
ed_list = ed if isinstance(ed, list) else ([ed] if ed else [])
dspy = [u for u in ed_list if isinstance(u, str) and u.endswith(".dspy")]
check(len(ed_list) == 3 and len(dspy) == 3, g, "%s editable = 3 个 .dspy URL" % tbl,
"editable=%s" % (ed_list,), "editable=%s,期望 3 个 .dspy" % (ed_list,))
# browserfields 非空
bf = params.get("browserfields")
bf_nonempty = bool(bf) and (len(bf) > 0 if isinstance(bf, (list, dict, str)) else True)
check(bf_nonempty, g, "%s browserfields 非空" % tbl,
"%d" % (len(bf) if hasattr(bf, "__len__") else 1), "browserfields 为空/缺失")
# tblname 与文件名一致
check(d.get("tblname") == tbl, g, "%s tblname 与文件名一致" % tbl,
"tblname=%s" % d.get("tblname"), "tblname=%s,文件名=%s" % (d.get("tblname"), tbl))
# --------------------------------------------------------------------------
# D. 代码质量
# --------------------------------------------------------------------------
def iter_py_files():
out = []
for root, dirs, fs in os.walk(REPO_ROOT):
dirs[:] = [x for x in dirs if x not in (".git", "__pycache__", ".venv", "build", "dist")]
for f in fs:
if f.endswith(".py"):
out.append(os.path.join(root, f))
return sorted(out)
def group_d():
g = "D.代码质量"
pys = iter_py_files()
check(len(pys) > 0, g, "存在 .py 源文件", "%d" % len(pys), "未找到任何 .py")
# py_compile 全量
try:
r = subprocess.run([sys.executable, "-m", "py_compile"] + pys,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out = (r.stdout or b"").decode("utf-8", "replace") + (r.stderr or b"").decode("utf-8", "replace")
check(r.returncode == 0, g, "py_compile 全量编译(%d 文件)" % len(pys),
"rc=0", "rc=%s\n%s" % (r.returncode, out.strip()[:2000]))
except Exception as e: # noqa: BLE001
record("FAIL", g, "py_compile 全量编译", "", "执行异常: %s" % e)
# 禁硬编码库名
hits = []
for p in pys:
try:
txt = open(p, "r", encoding="utf-8").read()
except Exception: # noqa: BLE001
continue
for i, line in enumerate(txt.splitlines(), 1):
s = line.strip()
if s.startswith("#"):
continue
for pat in HARDCODE_DB_PATTERNS:
if re.search(pat, line):
hits.append("%s:%d:%s" % (os.path.relpath(p, REPO_ROOT), i, s[:120]))
check(not hits, g, "禁硬编码库名DBNAME=/dbname=''/DB_NAME=",
"无命中,统一走 ServerEnv().get_module_dbname('pbl_blueprint')",
"命中 %d 处:\n %s" % (len(hits), "\n ".join(hits[:20])))
# sqlor API 白名单
used = set()
for p in pys:
try:
txt = open(p, "r", encoding="utf-8").read()
except Exception: # noqa: BLE001
continue
for m in re.finditer(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)", txt):
used.add(m.group(1))
illegal = sorted(used - SQLOR_WHITELIST)
check(not illegal, g, "sqlor API 白名单(仅 C/U/D/R/I/sqlExe",
"实际使用: %s" % (sorted(used) or ""),
"非法 API: %s(禁编造 save/list/insert" % ",".join(illegal))
# --------------------------------------------------------------------------
# E. 函数三处注册同步
# --------------------------------------------------------------------------
def group_e():
g = "E.注册同步"
init_py = os.path.join(PKG_DIR, "__init__.py")
loader = os.path.join(PKG_DIR, "init.py")
if not (os.path.exists(init_py) and os.path.exists(loader)):
record("FAIL", g, "__init__.py / init.py 存在", "", "缺失,无法核验三处注册")
return
init_txt = open(init_py, "r", encoding="utf-8").read()
load_txt = open(loader, "r", encoding="utf-8").read()
# errors.py 14 符号在 __init__.py 中导出
lack = [s for s in ERROR_SYMBOLS if s not in init_txt]
check(not lack, g, "__init__.py 导出 errors 14 符号",
"14 符号齐备", "缺导出: %s" % ",".join(lack))
# load_pbl_blueprint 入口存在
check("def load_pbl_blueprint" in load_txt, g, "init.py 定义 load_pbl_blueprint()",
"入口存在", "未找到 load_pbl_blueprint 定义")
# get_module_dbname 取库名(禁硬编码)
check("get_module_dbname" in load_txt, g, "init.py 用 get_module_dbname 取库名",
"符合规范", "未使用 ServerEnv().get_module_dbname")
# env 注册api 函数挂到 ServerEnv
has_reg = bool(re.search(r"(register|set_api|env\[|setattr\(|add_function|reg_func)", load_txt))
check(has_reg, g, "init.py 向 env 注册契约函数", "发现注册语句", "未发现任何注册语句")
# load_path.py RBAC 路径显式枚举、无通配符
lp = os.path.join(HERE, "load_path.py")
if os.path.exists(lp):
txt = open(lp, "r", encoding="utf-8").read()
wild = []
for i, line in enumerate(txt.splitlines(), 1):
s = line.strip()
if s.startswith("#"):
continue
for m in re.finditer(r"['\"]([^'\"]*)['\"]", line):
v = m.group(1)
if ("%" in v or "*" in v) and "/" in v:
wild.append("%d:%s" % (i, v))
check(not wild, g, "load_path.py RBAC 路径无 %/* 通配符",
"全部显式枚举", "发现通配路径: %s" % ";".join(wild[:10]))
check(len(txt.strip()) > 0, g, "load_path.py 非空", "已注册路径", "文件为空")
else:
record("FAIL", g, "load_path.py 存在", "", "缺失 %s" % lp)
# --------------------------------------------------------------------------
# F. 契约运行import
# --------------------------------------------------------------------------
def group_f():
g = "F.契约运行"
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
# F1: errors.py 可独立 import 且 14 符号齐备
try:
import importlib
m = importlib.import_module("pbl_blueprint.errors")
lack = [s for s in ERROR_SYMBOLS if not hasattr(m, s)]
check(not lack, g, "pbl_blueprint.errors 导出 14 符号",
"14 符号齐备", "缺符号: %s" % ",".join(lack))
# 语义抽检
r_ok = m.ok({"id": "1"})
check(m.is_ok(r_ok) and r_ok["code"] == m.ERR_OK, g, "ok() 返回 code=0",
"%s" % (r_ok,), "异常: %s" % (r_ok,))
r_fail = m.fail(m.ERR_TENANT_MISSING)
check(r_fail["code"] == m.ERR_TENANT_MISSING and not m.is_ok(r_fail),
g, "fail(ERR_TENANT_MISSING) 非成功", "%s" % (r_fail,), "%s" % (r_fail,))
r_err = m.err()
check(r_err["code"] == m.ERR_INTERNAL, g, "err() 缺省 ERR_INTERNAL",
"%s" % (r_err,), "%s" % (r_err,))
try:
raise m.PblBlueprintError(m.ERR_NOT_FOUND, "蓝图不存在")
except m.PblBlueprintError as e:
check(e.code == m.ERR_NOT_FOUND and e.to_dict()["code"] == m.ERR_NOT_FOUND,
g, "PblBlueprintError 抛出与 to_dict()",
"%s" % (e.to_dict(),), "%s" % (e.to_dict(),))
except Exception as e: # noqa: BLE001
record("FAIL", g, "pbl_blueprint.errors 可 import", "", "%s: %s" % (type(e).__name__, e))
# F2: 整包 import依赖 sqlor/ahserver 运行时则 SKIP不算 FAIL
try:
import importlib
importlib.import_module("pbl_blueprint")
record("PASS", g, "import pbl_blueprint 整包", "成功")
except ImportError as e:
miss = str(e)
if any(k in miss for k in ("sqlor", "ahserver", "sage", "sagelib", "DictObject", "DBPools")):
record("SKIP", g, "import pbl_blueprint 整包",
"运行时依赖缺失CI 环境预期): %s" % miss)
else:
record("FAIL", g, "import pbl_blueprint 整包", "", "ImportError: %s" % miss)
except Exception as e: # noqa: BLE001
record("FAIL", g, "import pbl_blueprint 整包", "", "%s: %s" % (type(e).__name__, e))
# --------------------------------------------------------------------------
# G. 租户 fail-closed
# --------------------------------------------------------------------------
def group_g():
g = "G.租户fail-closed"
tp = os.path.join(PKG_DIR, "tenant.py")
if not os.path.exists(tp):
record("FAIL", g, "tenant.py 存在", "", "缺失 %s" % tp)
return
txt = open(tp, "r", encoding="utf-8").read()
check("ERR_TENANT_MISSING" in txt, g, "tenant.py 使用 ERR_TENANT_MISSING 拒绝",
"fail-closed 拒绝路径存在", "未见 ERR_TENANT_MISSING")
# 不得有默认租户兜底
fallback = re.search(r"tenant_id\s*=\s*tenant_id\s+or\s+['\"]", txt) or \
re.search(r"DEFAULT_TENANT\s*=\s*['\"][^'\"]+['\"]", txt)
check(fallback is None, g, "tenant.py 无默认租户兜底",
"缺失即拒绝,不静默填充", "发现默认租户兜底: %s" % (fallback.group(0) if fallback else ""))
# 运行时抽检(可 import 时)
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
try:
import importlib
t = importlib.import_module("pbl_blueprint.tenant")
fn = None
for cand in ("require_tenant", "require_tenant_id", "get_tenant_id", "current_tenant_id"):
if hasattr(t, cand):
fn = getattr(t, cand)
break
if fn is None:
record("SKIP", g, "租户缺失运行时拒绝", "未找到租户取值函数,静态检查已覆盖")
else:
try:
fn({})
record("FAIL", g, "租户缺失运行时拒绝", "", "缺 tenant_id 未抛错/未拒绝")
except Exception as e: # noqa: BLE001
record("PASS", g, "租户缺失运行时拒绝",
"%s: %s" % (type(e).__name__, e))
except ImportError as e:
record("SKIP", g, "租户缺失运行时拒绝", "运行时依赖缺失: %s" % e)
except Exception as e: # noqa: BLE001
record("SKIP", g, "租户缺失运行时拒绝", "%s: %s" % (type(e).__name__, e))
# --------------------------------------------------------------------------
# H. DDL 生成
# --------------------------------------------------------------------------
def group_h():
g = "H.DDL生成"
if not os.path.isdir(SQL_DIR):
record("FAIL", g, "sql 目录", "缺失 %s" % SQL_DIR)
return
sqls = sorted([f for f in os.listdir(SQL_DIR) if f.endswith(".sql")])
check(len(sqls) >= 2, g, "sql/*.sql 存在core + subobjects",
"%s" % ",".join(sqls), "实际 %d 个: %s" % (len(sqls), sqls))
blob = ""
for fn in sqls:
try:
blob += open(os.path.join(SQL_DIR, fn), "r", encoding="utf-8").read() + "\n"
except Exception as e: # noqa: BLE001
record("FAIL", g, "%s 可读" % fn, "", "%s" % e)
ddl_tables = set(m.group(1).lower() for m in re.finditer(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?([A-Za-z0-9_]+)[`\"]?", blob, re.I))
missing = [t for t in EXPECTED_TABLES if t.lower() not in ddl_tables]
check(not missing, g, "DDL 覆盖 11 张表",
"CREATE TABLE %d" % len(ddl_tables), "DDL 缺表: %s" % ",".join(missing))
# 每张表 DDL 含 tenant_id
no_tenant = []
for t in EXPECTED_TABLES:
seg = re.search(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?%s[`\"]?\s*\((.*?)\n\)" % re.escape(t),
blob, re.I | re.S)
body = seg.group(1) if seg else ""
if seg and "tenant_id" not in body:
no_tenant.append(t)
if no_tenant:
record("FAIL", g, "DDL 各表含 tenant_id", "", "缺 tenant_id: %s" % ",".join(no_tenant))
else:
record("PASS", g, "DDL 各表含 tenant_id", "已核验(或 DDL 由 models 生成)")
# models 与 DDL 表集合一致
if os.path.isdir(MODELS_DIR):
mset = set(f[:-5] for f in os.listdir(MODELS_DIR) if f.endswith(".json"))
diff1 = sorted(mset - set(EXPECTED_TABLES))
check(not diff1, g, "models 无多余表", "一致", "models 多出: %s" % ",".join(diff1))
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
print("=" * 78)
print("pbl_blueprint M1a 自查门禁")
print("仓库根 : %s" % REPO_ROOT)
print("包目录 : %s" % PKG_DIR)
print("python : %s" % sys.version.replace("\n", " "))
print("=" * 78)
if not os.path.isdir(REPO_ROOT):
print("FATAL: 仓库根不存在 %s" % REPO_ROOT)
return 2
for fn in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h):
print("-" * 78)
try:
fn()
except Exception as e: # noqa: BLE001
record("FAIL", fn.__name__, "检查组执行异常", "%s: %s" % (type(e).__name__, e))
total = len(RESULTS)
n_pass = sum(1 for r in RESULTS if r[0] == "PASS")
n_fail = sum(1 for r in RESULTS if r[0] == "FAIL")
n_skip = sum(1 for r in RESULTS if r[0] == "SKIP")
print("-" * 78)
print("统计: 总计 %d | PASS %d | FAIL %d | SKIP %d" % (total, n_pass, n_fail, n_skip))
if n_fail:
print("失败项清单:")
for st, gp, nm, dt in RESULTS:
if st == "FAIL":
print(" - %s | %s | %s" % (gp, nm, (dt or "").replace("\n", " ")[:300]))
print("结论: HAS FAILURES")
# ★ 本轮修复项 B有失败项必须 exit 非 0否则门禁形同虚设
return 1
print("结论: ALL PASS%s" % ("(含 %d 项 SKIP运行时依赖缺失不计失败" % n_skip if n_skip else ""))
return 0
if __name__ == "__main__":
try:
rc = main()
except KeyboardInterrupt:
rc = 2
except Exception as _e: # noqa: BLE001
print("FATAL: 自查脚本自身异常: %s: %s" % (type(_e).__name__, _e))
rc = 2
print("exit_code=%s" % rc)
sys.exit(rc)