pbl_blueprint/tools/m1b_selfcheck.py
2026-09-16 19:36:42 +08:00

466 lines
20 KiB
Python
Raw Permalink 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 -*-
"""M1b 内容级自检工具QC 取证用)。
用途:把 QC 退回意见 #3/#4/#6 要求的「内容级核查」变成可重复执行、可留证据的
机械检查。运行:
cd modules/pbl_blueprint && python3 tools/m1b_selfcheck.py
退出码 0 = 全部通过;非 0 = 有 FAIL 项(打印明细)。
检查项:
A. DDL 合规sql/m1b_ddl.sql
A1 恰好 4 条 CREATE TABLE IF NOT EXISTS且为 4 张目标表
A2 零 FOREIGN KEY
A3 零 PostgreSQL 方言BIGSERIAL / nextval / SERIAL
A4 零对既有基表的 DDL/DML 动词ALTER/UPDATE/DELETE/DROP/TRUNCATE/INSERT
A5 每个 UNIQUE KEY 首列为 tenant_key规避 NULL 不去重)
A6 mariadb 方言要素齐备InnoDB + utf8mb4 + AUTO_INCREMENT
B. 表模型合规models/m1b/*.json
B1 4 个文件均可 json.load
B2 四段式summary(数组,含 primary) / fields(数组) / indexes(数组) / codes(数组)
B3 models 与 DDL 表名一一对应(双向)
B4 models 字段集合与 DDL 列集合一一对应(双向,逐表)
B5 models 索引集合与 DDL 索引集合一一对应(逐表,含 unique 标记)
C. CRUD 定义合规json/m1b/*.json
C1 4 个文件均可 json.load
C2 含 tblname 且指向本模块 4 表之一
C3 含 params.editableQC#3 指出的格式偏差)
C4 new/update/delete_data_url 在 params 顶层(非仅嵌套在 editable 内)
C5 json/ 下不含表定义特征键summary/fields/indexes——即 json/ 只放 CRUD
D. Python 源码内容级核查pbl_blueprint/m1b_*.py
D1 零硬编码库名(无 DBNAME = 'xxx' / dbname = 'xxx' 字面量赋值)
D2 取库名走 get_module_dbname
D3 只用 sqlor 标准 APIsor.C/U/D/R/I/sqlExe/sqlPaging无编造 API
D4 全部 py_compile 通过
D5 每个 .py 有效语句数 > 0非空壳
E. 三处同步注册核验QC#4
E1 m1b_api.py 定义 register_m1b_routes
E2 __init__.py 导出 register_m1b_routesimport 行)
E3 init.py 在 load_pbl_blueprint 内引用 register_m1b_routesenv 注册/调用)
E4 M1b 每个对外函数在三处齐备(实现 / __init__ 导出 / init.py 注册)
F. M1a 既有代码零越权改动QC#6
F1 git diff 中 M1a 文件(非 m1b_* / 非 models|json|sql|tests|docs/tools 的
既有 .py只允许「追加式」改动新增行不得删除既有 M1a 函数定义行
"""
import ast
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
M1B_TABLES = [
"pbl_blueprint_template",
"pbl_blueprint_ref",
"pbl_subobject_ext",
"pbl_ext_field_def",
]
DDL_REL = os.path.join("sql", "m1b_ddl.sql")
MODELS_REL = os.path.join("pbl_blueprint", "models", "m1b")
JSON_REL = os.path.join("pbl_blueprint", "json", "m1b")
# 编造的 sqlor APImodule-development-spec 明确:只有 C/U/D/R/I/sqlExe
FAKE_SQLOR = [
"sqlor.save", "sor.save", "sqlor.list", "sor.list", "sqlor.one", "sor.one",
"sqlor.delete", "sor.delete", "sqlor.insert", "sor.insert", "sqlor.query",
"sor.query", "sqlor.update", "sor.update", "sqlor.create", "sor.create",
"sqlor.select", "sor.select", "sqlor.find", "sor.find", "sqlor.get",
"sor.get_one", "sor.fetch", "sqlor.fetch",
]
# 允许的 sqlor 调用(白名单,用于 D3 正向取证)
OK_SQLOR = re.compile(r"\bsor\.(C|U|D|R|I|sqlExe|sqlPaging|sqlorContext)\s*\(")
results = []
def record(check_id, ok, detail):
results.append((check_id, bool(ok), detail))
flag = "PASS" if ok else "FAIL"
print("[%s] %s :: %s" % (flag, check_id, detail))
def p(*parts):
return os.path.join(REPO, *parts)
def read(rel):
with open(p(rel), "r", encoding="utf-8") as f:
return f.read()
def strip_sql_comments(sql):
"""去掉 -- 行注释与 /* */ 块注释,避免注释文本干扰动词扫描。"""
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
out = []
for line in sql.splitlines():
idx = line.find("--")
if idx >= 0:
line = line[:idx]
out.append(line)
return "\n".join(out)
# ---------------------------------------------------------------- A. DDL
def check_ddl():
if not os.path.exists(p(DDL_REL)):
record("A0", False, "sql/m1b_ddl.sql 不存在")
return None
raw = read(DDL_REL)
body = strip_sql_comments(raw)
creates = re.findall(
r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?", body, flags=re.I)
record("A1", len(creates) == 4 and sorted(creates) == sorted(M1B_TABLES),
"CREATE TABLE IF NOT EXISTS 共 %d 条,表=%s" % (len(creates), creates))
fk = len(re.findall(r"FOREIGN\s+KEY", body, flags=re.I))
record("A2", fk == 0, "FOREIGN KEY 出现 %d 次(应为 0" % fk)
pg = re.findall(r"\b(BIGSERIAL|SERIAL|nextval)\b", body, flags=re.I)
record("A3", not pg, "PostgreSQL 方言命中 %s(应为空)" % (pg or ""))
# A4任何针对既有基表的 DDL/DML 动词;同时全文(去注释)不得出现这些动词
verbs = re.findall(
r"\b(ALTER|UPDATE|DELETE|DROP|TRUNCATE|INSERT|REPLACE)\b", body, flags=re.I)
base_hits = re.findall(
r"\b(CREATE|ALTER|UPDATE|DELETE|DROP|TRUNCATE|INSERT)\b[^;]{0,120}?"
r"\b(world|scene|entity|script|world_snapshot|scense)\b",
body, flags=re.I)
record("A4", not verbs and not base_hits,
"去注释后 DDL/DML 动词命中 %s;基表语句命中 %s(均应为空)"
% (sorted(set(v.upper() for v in verbs)) or "", base_hits or ""))
# A5每个 UNIQUE KEY 首列必须是 tenant_key
bad_uk = []
uks = re.findall(r"UNIQUE\s+KEY\s+`?(\w+)`?\s*\(([^)]*)\)", body, flags=re.I)
for name, cols in uks:
first = cols.split(",")[0].strip().strip("`").strip()
if first != "tenant_key":
bad_uk.append("%s(首列=%s)" % (name, first))
record("A5", uks and not bad_uk,
"UNIQUE KEY 共 %d 个,首列非 tenant_key 的:%s" % (len(uks), bad_uk or ""))
need = ["ENGINE=InnoDB", "utf8mb4", "AUTO_INCREMENT"]
miss = [n for n in need if n.lower() not in body.lower()]
record("A6", not miss, "mariadb 方言要素缺失 %s(应为空)" % (miss or ""))
return body
# ------------------------------------------------------- B. models 四段式
def parse_ddl_tables(body):
"""从 DDL 正文解析 {table: {"cols": [...], "indexes": {name: (cols, unique)}}}"""
tables = {}
for m in re.finditer(
r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?\s*\((.*?)\n\)\s*ENGINE",
body, flags=re.I | re.S):
tname, inner = m.group(1), m.group(2)
cols, idx = [], {}
for line in inner.splitlines():
s = line.strip().rstrip(",").strip()
if not s:
continue
up = s.upper()
if up.startswith("PRIMARY KEY"):
continue
if up.startswith("UNIQUE KEY") or up.startswith("KEY ") or up.startswith("INDEX "):
nm = re.match(r"(?:UNIQUE\s+)?(?:KEY|INDEX)\s+`?(\w+)`?\s*\(([^)]*)\)",
s, flags=re.I)
if nm:
cs = [c.strip().strip("`") for c in nm.group(2).split(",")]
idx[nm.group(1)] = (cs, up.startswith("UNIQUE"))
continue
cm = re.match(r"`(\w+)`", s)
if cm:
cols.append(cm.group(1))
tables[tname] = {"cols": cols, "indexes": idx}
return tables
def check_models(ddl_tables):
mdir = p(MODELS_REL)
if not os.path.isdir(mdir):
record("B1", False, "models/m1b/ 目录不存在")
return
files = sorted(f for f in os.listdir(mdir) if f.endswith(".json"))
record("B1", len(files) == 4, "models/m1b/ JSON 文件 %d 个:%s" % (len(files), files))
model_tables = {}
four_ok, four_bad = True, []
for fn in files:
try:
with open(os.path.join(mdir, fn), "r", encoding="utf-8") as f:
d = json.load(f)
except Exception as e:
four_ok = False
four_bad.append("%s: json.load 失败 %s" % (fn, e))
continue
for seg in ("summary", "fields", "indexes", "codes"):
if not isinstance(d.get(seg), list):
four_ok = False
four_bad.append("%s: 缺 %s 数组" % (fn, seg))
sm = d.get("summary") or []
if not (sm and isinstance(sm[0], dict) and sm[0].get("primary")):
four_ok = False
four_bad.append("%s: summary[0].primary 缺失" % fn)
if sm:
model_tables[sm[0].get("table")] = d
record("B2", four_ok, "四段式(summary/fields/indexes/codes) 违规:%s" % (four_bad or ""))
only_model = sorted(set(model_tables) - set(ddl_tables))
only_ddl = sorted(set(ddl_tables) - set(model_tables))
record("B3", not only_model and not only_ddl,
"models↔DDL 表名对应:仅 models 有 %s,仅 DDL 有 %s(均应为空)"
% (only_model or "", only_ddl or ""))
col_bad, idx_bad = [], []
for t, d in model_tables.items():
if t not in ddl_tables:
continue
mcols = [f.get("name") for f in d.get("fields", [])]
dcols = ddl_tables[t]["cols"]
if sorted(mcols) != sorted(dcols):
col_bad.append("%s: 仅models=%s 仅DDL=%s"
% (t, sorted(set(mcols) - set(dcols)),
sorted(set(dcols) - set(mcols))))
midx = {}
for ix in d.get("indexes", []):
midx[ix.get("name")] = (ix.get("fields"), bool(ix.get("unique")))
didx = ddl_tables[t]["indexes"]
if sorted(midx) != sorted(didx):
idx_bad.append("%s: 索引名不一致 仅models=%s 仅DDL=%s"
% (t, sorted(set(midx) - set(didx)),
sorted(set(didx) - set(midx))))
else:
for k in midx:
if list(midx[k][0]) != list(didx[k][0]) or midx[k][1] != didx[k][1]:
idx_bad.append("%s.%s: models=%s DDL=%s" % (t, k, midx[k], didx[k]))
record("B4", not col_bad, "models↔DDL 列一一对应违规:%s" % (col_bad or ""))
record("B5", not idx_bad, "models↔DDL 索引一一对应违规:%s" % (idx_bad or ""))
# ---------------------------------------------------------- C. json CRUD
def check_crud():
jdir = p(JSON_REL)
if not os.path.isdir(jdir):
record("C1", False, "json/m1b/ 目录不存在")
return
files = sorted(f for f in os.listdir(jdir) if f.endswith(".json"))
record("C1", len(files) == 4, "json/m1b/ JSON 文件 %d 个:%s" % (len(files), files))
bad_tbl, bad_edit, bad_url, bad_model = [], [], [], []
for fn in files:
try:
with open(os.path.join(jdir, fn), "r", encoding="utf-8") as f:
d = json.load(f)
except Exception as e:
bad_tbl.append("%s: json.load 失败 %s" % (fn, e))
continue
if d.get("tblname") not in M1B_TABLES:
bad_tbl.append("%s: tblname=%r" % (fn, d.get("tblname")))
prm = d.get("params") or {}
if not isinstance(prm.get("editable"), dict):
bad_edit.append("%s: 缺 params.editable" % fn)
for k in ("new_data_url", "update_data_url", "delete_data_url"):
if not prm.get(k):
bad_url.append("%s: params 顶层缺 %s" % (fn, k))
for seg in ("summary", "fields", "indexes"):
if seg in d:
bad_model.append("%s: 含表定义段 %sjson/ 只放 CRUD" % (fn, seg))
record("C2", not bad_tbl, "tblname 违规:%s" % (bad_tbl or ""))
record("C3", not bad_edit, "params.editable 违规:%s" % (bad_edit or ""))
record("C4", not bad_url, "params 顶层 *_data_url 违规:%s" % (bad_url or ""))
record("C5", not bad_model, "json/ 混入表定义:%s" % (bad_model or ""))
# ------------------------------------------------------- D. Python 源码
def m1b_py_files():
pkg = p("pbl_blueprint")
return sorted(f for f in os.listdir(pkg)
if f.startswith("m1b_") and f.endswith(".py"))
def check_python():
files = m1b_py_files()
if not files:
record("D1", False, "未找到 pbl_blueprint/m1b_*.py")
return {}
hard, no_dbname, fake, compile_bad, empty = [], [], [], [], []
funcs = {}
hard_re = re.compile(
r"""^\s*(?:DBNAME|dbname|db_name|DATABASE|database)\s*=\s*['"][^'"]+['"]""",
re.M)
for fn in files:
src = read(os.path.join("pbl_blueprint", fn))
if hard_re.search(src):
hard.append(fn)
if "get_module_dbname" not in src and "dbname" in src.lower():
no_dbname.append(fn)
for api in FAKE_SQLOR:
if api in src:
fake.append("%s: %s" % (fn, api))
try:
compile(src, fn, "exec")
except SyntaxError as e:
compile_bad.append("%s: %s" % (fn, e))
try:
tree = ast.parse(src)
except SyntaxError:
continue
stmts = [n for n in ast.walk(tree)
if isinstance(n, ast.stmt) and not isinstance(n, (ast.Pass,))]
if len(stmts) < 3:
empty.append("%s: 有效语句 %d" % (fn, len(stmts)))
for n in tree.body:
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
funcs.setdefault(n.name, []).append(fn)
record("D1", not hard, "硬编码库名文件:%s(应为空)" % (hard or ""))
record("D2", not no_dbname, "未走 get_module_dbname 的文件:%s" % (no_dbname or ""))
record("D3", not fake, "编造 sqlor API%s(应为空;白名单 sor.C/U/D/R/I/sqlExe/sqlPaging"
% (fake or ""))
record("D4", not compile_bad, "py_compile 失败:%s" % (compile_bad or ""))
record("D5", not empty, "疑似空壳文件:%s" % (empty or ""))
return funcs
# --------------------------------------------------- E. 三处同步注册
def check_registration(funcs):
api = read(os.path.join("pbl_blueprint", "m1b_api.py")) if os.path.exists(
p("pbl_blueprint", "m1b_api.py")) else ""
initpkg = read(os.path.join("pbl_blueprint", "__init__.py"))
initpy = read(os.path.join("pbl_blueprint", "init.py"))
record("E1", "def register_m1b_routes" in api,
"m1b_api.py 定义 register_m1b_routes%s"
% ("" if "def register_m1b_routes" in api else ""))
record("E2", "register_m1b_routes" in initpkg,
"__init__.py 导出 register_m1b_routes%s"
% ("" if "register_m1b_routes" in initpkg else ""))
record("E3", "register_m1b_routes" in initpy,
"init.py 引用/注册 register_m1b_routes%s"
% ("" if "register_m1b_routes" in initpy else ""))
# E4M1b 对外函数m1b_*.py 顶层定义)三处齐备
# ① 实现m1b_*.py 中 def/async def
# ② 导出__init__.py 出现该名from .m1b_x import ... 或 __all__
# ③ 注册init.py 出现该名import + env.xxx= 赋值 或 直接调用)
PUBLIC_PREFIX = ("m1b_", "init_m1b", "register_m1b", "ensure_m1b",
"load_m1b", "seed_platform", "ensure_platform")
public = sorted(n for n in funcs if n.startswith(PUBLIC_PREFIX))
miss_exp = [n for n in public if not re.search(r"\b%s\b" % re.escape(n), initpkg)]
miss_reg = [n for n in public if not re.search(r"\b%s\b" % re.escape(n), initpy)]
# 注册形态取证env.xxx = 赋值 或 函数调用 或 import 行
reg_form = {}
for n in public:
forms = []
if re.search(r"env\.%s\s*=" % re.escape(n), initpy):
forms.append("env赋值")
if re.search(r'["\']%s["\']' % re.escape(n), initpy) and \
re.search(r"M1B_ENV_EXPORTS|setattr\(env", initpy):
forms.append("M1B_ENV_EXPORTS声明式env注册")
if re.search(r"\b%s\s*\(" % re.escape(n), initpy):
forms.append("调用")
if re.search(r"^\s*from\s+\.m1b_\w*\s+import[^\n]*\b%s\b" % re.escape(n),
initpy, re.M) or re.search(r"^\s*from\s+\.\s+import[^\n]*\b%s\b"
% re.escape(n), initpy, re.M):
forms.append("import")
reg_form[n] = forms or ["未注册"]
record("E4", not miss_exp and not miss_reg,
"M1b 对外函数 %d__init__ 未导出 %sinit.py 未注册 %s"
% (len(public), miss_exp or "", miss_reg or ""))
for n in public:
record("E4.%s" % n, reg_form[n] != ["未注册"],
"init.py 注册形态=%s" % "/".join(reg_form[n]))
def check_m1a_untouched():
"""F1/F2M1b 改动不得越权破坏 M1a 既有代码。
界定:
* M1a 业务文件 = pbl_blueprint/ 下非 m1b_* 的 .py且排除共享入口
__init__.py / init.py这两个文件是 module-development-spec 规定的
「三处同步」注册点M1b 必须在此追加导出/注册,属合规追加而非越权)。
* 判定M1a 业务文件不得被修改/删除git diff 无 M/D
共享入口只允许「追加式」改动——既有 def 行零删除。
"""
SHARED = ("__init__.py", "init.py")
try:
out = subprocess.run(["git", "diff", "--name-status", "HEAD", "--", "pbl_blueprint/"],
cwd=REPO, capture_output=True, text=True, timeout=30)
lines = [l for l in out.stdout.splitlines() if l.strip()]
except Exception as e:
lines = []
print(" (git diff 不可用:%s)" % e)
m1a_biz_bad, shared_del = [], []
for l in lines:
parts = l.split("\t")
if len(parts) < 2:
continue
st, path = parts[0], parts[-1]
base = os.path.basename(path)
if base.startswith("m1b_") or "/m1b/" in path or not base.endswith(".py"):
continue
if base in SHARED:
# 共享入口:只查「既有 def 行是否被删除」
try:
d = subprocess.run(["git", "diff", "-U0", "HEAD", "--", path],
cwd=REPO, capture_output=True, text=True, timeout=30)
removed_defs = [x for x in d.stdout.splitlines()
if x.startswith("-") and not x.startswith("---")
and re.match(r"-\s*(async\s+)?def\s+\w+", x)]
if removed_defs:
shared_del.append("%s 删除既有函数 %d 个: %s"
% (base, len(removed_defs), removed_defs[:3]))
except Exception:
pass
continue
if st.startswith("M") or st.startswith("D"):
m1a_biz_bad.append("%s %s" % (st, path))
# F2 静态核验M1a 关键入口与 11 表定义仍在
initpy = read(os.path.join("pbl_blueprint", "init.py"))
initpkg = read(os.path.join("pbl_blueprint", "__init__.py"))
m1a_ok = ("load_pbl_blueprint" in initpy and "load_pbl_blueprint" in initpkg
and "TABLE_NAMES" in initpy)
record("F1", not m1a_biz_bad and not shared_del and m1a_ok,
"M1a 业务文件被改/删:%s;共享入口删除既有函数:%s"
"load_pbl_blueprint+TABLE_NAMES 完好:%s"
% (m1a_biz_bad or "", shared_del or "", m1a_ok))
def main():
print("=" * 72)
print("M1b 内容级自检 repo=%s" % REPO)
print("=" * 72)
body = check_ddl()
ddl_tables = parse_ddl_tables(body) if body else {}
check_models(ddl_tables)
check_crud()
funcs = check_python()
check_registration(funcs)
check_m1a_untouched()
total = len(results)
passed = sum(1 for _, ok, _ in results if ok)
print("-" * 72)
print("结果:%d/%d PASS" % (passed, total))
for cid, ok, detail in results:
if not ok:
print(" FAIL %s :: %s" % (cid, detail))
print("-" * 72)
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())