397 lines
18 KiB
Python
397 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""M11b-2b 表定义自检(database-table-definition-spec 机械断言)。
|
||
|
||
覆盖范围(QC #4 要求):
|
||
1. 全部 4 个落点都跑同一套 spec 断言:
|
||
- modules/world_sync/models/pbl_runtime_event.json (唯一真源)
|
||
- modules/world_sync/models/pbl_entity_state.json (唯一真源)
|
||
- apps/scense/pkgs/world_sync/models/pbl_runtime_event.json (打包镜像)
|
||
- apps/scense/pkgs/world_sync/models/pbl_entity_state.json (打包镜像)
|
||
2. 真源与镜像 sha256 两两相同的一致性断言(消除内容级分叉)。
|
||
3. 各段键集合 ⊆ spec 白名单的断言(summary/fields/indexes/codes 均不得出现未定义键)。
|
||
4. 抽象类型断言(禁止 VARCHAR/BIGINT/DATETIME(3)/JSON 等方言具体类型)。
|
||
5. 主键 id str(32) 断言 + 与 pbl_runtime_sql.py 实际列名交叉核对。
|
||
|
||
任一断言不符即 RESULT: FAIL 且退出码 1。
|
||
用法: python3 modules/world_sync/scripts/m11b2_selftest.py
|
||
|
||
────────────────────────────────────────────────────────────────────────────
|
||
[cross-check-sql] 提取器的列名解析规则(M11b-2d-B 固化,下次结构变更照此扩展)
|
||
────────────────────────────────────────────────────────────────────────────
|
||
pbl_runtime_sql.py 里 ``EVENT_COLUMNS`` / ``STATE_COLUMNS`` 有两种合法真源形态,
|
||
提取器必须**同时**兼容,否则自检会在代码演进时假阳(M11b-2c 实跑 4 FAIL 即此因):
|
||
|
||
形态 A(旧·两段式手抄字面量)
|
||
EVENT_COLUMNS = (
|
||
"id", "event_id", ...
|
||
)
|
||
→ 正则 ``NAME\\s*=\\s*\\((.*?)\\)`` + 引号内标识符,直接取字面量列名。
|
||
|
||
形态 B(M11b-2b 起·四段式 models/*.json 机械派生,当前真态)
|
||
STATE_TABLE = "pbl_entity_state"
|
||
STATE_COLUMNS = columns_of(STATE_TABLE)
|
||
列名不再手抄,由 :mod:`world_sync.pbl_runtime_authority` 按
|
||
``models/<table>.json`` 的 ``fields[].name``(spec 四段式第三段之前的
|
||
fields 段)有序导出,fail-closed(模型缺失/非四段式/fields 空 →
|
||
AuthorityResolutionError,不退回旧常量)。
|
||
解析优先级:
|
||
B1 运行时取真值:sys.path 注入 ``modules/world_sync``,import
|
||
``world_sync.pbl_runtime_sql``,读模块属性(= 生产代码实际使用的列契约,
|
||
与「手抄正则」相比更强:常量表达式怎么变都能拿到最终值)。
|
||
B2 静态回落:import 失败(依赖/环境差异)时,按形态 B 正则解析出
|
||
``columns_of(<TABLE_CONST>)`` → ``<TABLE_CONST> = "<table>"`` → 读唯一真源
|
||
``modules/world_sync/models/<table>.json`` 的 ``fields[].name``。
|
||
A/B 都解析不出 → 记 FAIL(不静默跳过),保证门禁不失效。
|
||
|
||
覆盖强度说明(未放宽任何断言):形态 A 原本只断「代码列 ⊆ fields 列」单向;
|
||
形态 B 下列集由 fields 派生,单向断言退化为恒真,故补两条等价替代断言把强度
|
||
恢复到「代码契约 == 表契约」双向:
|
||
- [cross-check-sql] 代码列 ⊆ fields 列(原有,语义不变)
|
||
- [cross-check-sql-nocov] fields 列 ⊆ 代码列(真源不得声明代码不认的幽灵列)
|
||
- [cross-check-sql-order] 代码列顺序 == fields 声明顺序(columns_of 有序导出契约)
|
||
- [cross-check-sql-count] 列数与代码注释宣称的列数一致(防静默丢列)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MODULE_DIR = os.path.abspath(os.path.join(HERE, "..")) # modules/world_sync
|
||
WS_ROOT = os.path.abspath(os.path.join(MODULE_DIR, "..", "..")) # 机构工作空间根
|
||
|
||
SRC_DIR = os.path.join("modules", "world_sync", "models")
|
||
MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models")
|
||
|
||
TABLES = ("pbl_runtime_event", "pbl_entity_state")
|
||
|
||
# ---- spec 白名单键(database-table-definition-spec)----
|
||
ROOT_KEYS = {"summary", "fields", "indexes", "codes"}
|
||
SUMMARY_KEYS = {"name", "title", "primary", "catelog", "comment"}
|
||
FIELD_KEYS = {"name", "title", "type", "length", "dec", "nullable", "default"}
|
||
INDEX_KEYS = {"name", "idxtype", "idxfields"}
|
||
CODE_KEYS = {"field", "table", "valuefield", "textfield", "cond"}
|
||
|
||
# 抽象类型(spec 表格)
|
||
ABSTRACT_TYPES = {
|
||
"str", "char", "short", "int", "long", "float", "double", "ddouble",
|
||
"decimal", "date", "time", "datetime", "timestamp", "text", "bin",
|
||
}
|
||
NEEDS_LENGTH = {"str", "char", "float", "double", "ddouble", "decimal"}
|
||
NEEDS_DEC = {"float", "double", "ddouble", "decimal"}
|
||
|
||
# 真源 fields 必须覆盖的代码侧列名(pbl_runtime_sql.py)
|
||
SQL_MODULE = os.path.join("modules", "world_sync", "world_sync", "pbl_runtime_sql.py")
|
||
COLS_IN_CODE = {
|
||
"pbl_runtime_event": "EVENT_COLUMNS",
|
||
"pbl_entity_state": "STATE_COLUMNS",
|
||
}
|
||
# 代码注释里宣称的列数(防静默丢列;改模型时同步更新此表,不一致即 FAIL)
|
||
DECLARED_COL_COUNT = {
|
||
"pbl_runtime_event": 25,
|
||
"pbl_entity_state": 10,
|
||
}
|
||
|
||
results = [] # (ok: bool, line: str)
|
||
_CODE_COLS_CACHE = {} # (tname, const) -> (cols|None, how)
|
||
|
||
|
||
def check(ok: bool, line: str) -> bool:
|
||
results.append((bool(ok), line))
|
||
return bool(ok)
|
||
|
||
|
||
def sha256_of(path: str) -> str:
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as fh:
|
||
for chunk in iter(lambda: fh.read(65536), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
def load(path: str):
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
return json.load(fh)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 代码侧列名提取(解析规则见文件头「[cross-check-sql] 提取器的列名解析规则」)
|
||
# --------------------------------------------------------------------------
|
||
def _cols_from_literal(src: str, const: str):
|
||
"""形态 A:NAME = ("a", "b", ...) 手抄字面量元组。"""
|
||
m = re.search(const + r"\s*=\s*\((.*?)\)", src, re.S)
|
||
if not m:
|
||
return None
|
||
cols = re.findall(r'"([a-zA-Z_][a-zA-Z0-9_]*)"', m.group(1))
|
||
return cols or None
|
||
|
||
|
||
def _table_from_columns_of(src: str, const: str):
|
||
"""形态 B 静态解析:NAME = columns_of(<TABLE_CONST>) → <TABLE_CONST> = "<table>"。"""
|
||
m = re.search(const + r"\s*=\s*columns_of\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)", src)
|
||
if not m:
|
||
return None
|
||
tconst = m.group(1)
|
||
m2 = re.search(r"\b" + re.escape(tconst) + r"\s*=\s*[\"']([a-zA-Z_][a-zA-Z0-9_]*)[\"']", src)
|
||
return m2.group(1) if m2 else None
|
||
|
||
|
||
def _cols_from_runtime(const: str):
|
||
"""形态 B1:import 生产模块,读运行时常量真值(最贴近实际 SQL 契约)。"""
|
||
try:
|
||
if MODULE_DIR not in sys.path:
|
||
sys.path.insert(0, MODULE_DIR)
|
||
mod = __import__("world_sync.pbl_runtime_sql", fromlist=["pbl_runtime_sql"])
|
||
cols = getattr(mod, const, None)
|
||
if isinstance(cols, (list, tuple)) and cols:
|
||
return [str(c) for c in cols]
|
||
except Exception: # noqa: BLE001 - 环境不具备时回落静态解析,不因此放宽断言
|
||
return None
|
||
return None
|
||
|
||
|
||
def _cols_from_authority_model(table: str):
|
||
"""形态 B2:读唯一真源 models/<table>.json 的 fields[].name(有序)。"""
|
||
p = os.path.join(WS_ROOT, SRC_DIR, table + ".json")
|
||
if not os.path.isfile(p):
|
||
return None
|
||
try:
|
||
doc = load(p)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
fields = doc.get("fields") or []
|
||
cols = [str(f.get("name")) for f in fields if f.get("name")]
|
||
return cols or None
|
||
|
||
|
||
def extract_code_columns(tname: str, const: str):
|
||
"""返回 (cols|None, how)。cols=None 表示解析失败(调用方记 FAIL,不静默跳过)。"""
|
||
key = (tname, const)
|
||
if key in _CODE_COLS_CACHE:
|
||
return _CODE_COLS_CACHE[key]
|
||
|
||
out = (None, "unresolved")
|
||
sql_path = os.path.join(WS_ROOT, SQL_MODULE)
|
||
if os.path.isfile(sql_path):
|
||
src = open(sql_path, "r", encoding="utf-8").read()
|
||
# A) 旧两段式字面量
|
||
cols = _cols_from_literal(src, const)
|
||
if cols:
|
||
out = (cols, "literal-tuple(形态A)")
|
||
else:
|
||
# B1) 运行时真值
|
||
cols = _cols_from_runtime(const)
|
||
if cols:
|
||
out = (cols, "runtime-import columns_of(形态B1)")
|
||
else:
|
||
# B2) 静态解析表名 → 唯一真源 models/*.json
|
||
table = _table_from_columns_of(src, const)
|
||
if table:
|
||
cols = _cols_from_authority_model(table)
|
||
if cols:
|
||
out = (cols, "static columns_of(%s) -> models/%s.json fields[]"
|
||
% (table, table) + "(形态B2)")
|
||
_CODE_COLS_CACHE[key] = out
|
||
return out
|
||
|
||
|
||
def assert_spec(rel: str) -> None:
|
||
"""对单个落点文件跑全套 spec 断言。"""
|
||
path = os.path.join(WS_ROOT, rel)
|
||
if not check(os.path.isfile(path), f"[exists] {rel}"):
|
||
return
|
||
try:
|
||
doc = load(path)
|
||
check(True, f"[json-parse] {rel} OK")
|
||
except Exception as exc: # noqa: BLE001
|
||
check(False, f"[json-parse] {rel} FAIL: {exc}")
|
||
return
|
||
|
||
# --- 根键四段式 ---
|
||
root = set(doc.keys())
|
||
check(root <= ROOT_KEYS, f"[root-keys] {rel} ⊆ summary/fields/indexes/codes -> {sorted(root)}"
|
||
+ ("" if root <= ROOT_KEYS else f" 多余={sorted(root - ROOT_KEYS)}"))
|
||
|
||
# --- summary: 恰好一条,键白名单 ---
|
||
summary = doc.get("summary") or []
|
||
check(len(summary) == 1, f"[summary-count] {rel} 恰好 1 条 -> {len(summary)}")
|
||
if not summary:
|
||
return
|
||
s0 = summary[0]
|
||
skeys = set(s0.keys())
|
||
check(skeys <= SUMMARY_KEYS, f"[summary-keys] {rel} ⊆ {sorted(SUMMARY_KEYS)}"
|
||
+ ("" if skeys <= SUMMARY_KEYS else f" 非白名单键={sorted(skeys - SUMMARY_KEYS)}"))
|
||
tname = os.path.basename(rel)[: -len(".json")]
|
||
check(s0.get("name") == tname, f"[summary-name] {rel} name=={tname} -> {s0.get('name')}")
|
||
check(bool(str(s0.get("title") or "").strip()), f"[summary-title] {rel} title 非空")
|
||
primary = s0.get("primary")
|
||
check(isinstance(primary, list) and primary == ["id"],
|
||
f"[summary-primary] {rel} primary==['id'] (array) -> {primary!r}")
|
||
check(str(s0.get("catelog") or "") in {"entity", "relation", "dimession", "indication", ""},
|
||
f"[summary-catelog] {rel} catelog 合法 -> {s0.get('catelog')}")
|
||
|
||
# --- fields ---
|
||
fields = doc.get("fields") or []
|
||
check(len(fields) >= 1, f"[fields-count] {rel} >=1 -> {len(fields)}")
|
||
names = []
|
||
for f in fields:
|
||
fn = str(f.get("name") or "?")
|
||
names.append(fn)
|
||
tag = f"{rel}#{fn}"
|
||
fkeys = set(f.keys())
|
||
check(fkeys <= FIELD_KEYS, f"[field-keys] {tag} ⊆ {sorted(FIELD_KEYS)}"
|
||
+ ("" if fkeys <= FIELD_KEYS else f" 非白名单键={sorted(fkeys - FIELD_KEYS)}"))
|
||
t = f.get("type")
|
||
check(t in ABSTRACT_TYPES, f"[abstract-type] {tag} type 抽象 -> {t!r}")
|
||
# 方言具体类型黑名单形态(含括号/空格的大写写法)
|
||
check(bool(re.fullmatch(r"[a-z]+", str(t or ""))), f"[type-lower-no-dialect] {tag} -> {t!r}")
|
||
check(bool(str(f.get("title") or "").strip()), f"[field-title] {tag} title 非空(DDL COMMENT 来源)")
|
||
if t in NEEDS_LENGTH:
|
||
ln = f.get("length")
|
||
check(isinstance(ln, int) and not isinstance(ln, bool) and ln > 0,
|
||
f"[field-length] {tag} length 为正整数 -> {ln!r}")
|
||
if t in NEEDS_DEC:
|
||
dc = f.get("dec")
|
||
check(isinstance(dc, int) and not isinstance(dc, bool) and dc > 0,
|
||
f"[field-dec] {tag} dec 为正整数 -> {dc!r}")
|
||
check(str(f.get("nullable") or "yes") in {"yes", "no"}, f"[field-nullable] {tag} -> {f.get('nullable')}")
|
||
check("id" in names, f"[field-id] {rel} 含 id 主键列")
|
||
idf = next((f for f in fields if f.get("name") == "id"), None)
|
||
if idf:
|
||
check(idf.get("type") == "str" and idf.get("length") == 32,
|
||
f"[id-str32] {rel} id type=str length=32 -> {idf.get('type')}/{idf.get('length')}")
|
||
check(idf.get("nullable") == "no", f"[id-notnull] {rel} id nullable=no")
|
||
check(len(names) == len(set(names)), f"[field-unique] {rel} 列名无重复")
|
||
|
||
# --- indexes ---
|
||
indexes = doc.get("indexes") or []
|
||
inames = []
|
||
for ix in indexes:
|
||
nm = str(ix.get("name") or "?")
|
||
inames.append(nm)
|
||
tag = f"{rel}#{nm}"
|
||
ikeys = set(ix.keys())
|
||
check(ikeys <= INDEX_KEYS, f"[index-keys] {tag} ⊆ {sorted(INDEX_KEYS)}"
|
||
+ ("" if ikeys <= INDEX_KEYS else f" 非白名单键={sorted(ikeys - INDEX_KEYS)}"))
|
||
check(ix.get("idxtype") in {"unique", "index"}, f"[index-type] {tag} -> {ix.get('idxtype')}")
|
||
ixfs = ix.get("idxfields")
|
||
check(isinstance(ixfs, list) and len(ixfs) > 0, f"[index-idxfields-array] {tag} -> {ixfs!r}")
|
||
if isinstance(ixfs, list):
|
||
unknown = [c for c in ixfs if c not in names]
|
||
check(not unknown, f"[index-cols-exist] {tag} 索引列都在 fields 中 -> {ixfs}"
|
||
+ ("" if not unknown else f" 缺列={unknown}"))
|
||
check(len(inames) == len(set(inames)), f"[index-unique-name] {rel} 索引名无重复")
|
||
# 租户隔离:所有唯一索引以 tenant_id 打头
|
||
for ix in indexes:
|
||
if ix.get("idxtype") == "unique":
|
||
ixfs = ix.get("idxfields") or []
|
||
check(bool(ixfs) and ixfs[0] == "tenant_id",
|
||
f"[tenant-first] {rel}#{ix.get('name')} 唯一索引 tenant_id 打头 -> {ixfs}")
|
||
|
||
# --- 交叉核对:代码里的列名全部在册(兼容形态 A/B,见文件头解析规则)---
|
||
const = COLS_IN_CODE.get(tname)
|
||
if const:
|
||
sql_path = os.path.join(WS_ROOT, SQL_MODULE)
|
||
if not os.path.isfile(sql_path):
|
||
check(False, f"[cross-check-sql] {rel} 代码侧真源缺失 {SQL_MODULE}")
|
||
else:
|
||
code_cols, how = extract_code_columns(tname, const)
|
||
if not code_cols:
|
||
check(False, f"[cross-check-sql] {rel} 未能解析 {const}"
|
||
f"(形态A字面量/形态B运行时/形态B静态 三级解析均失败)")
|
||
else:
|
||
missing = [c for c in code_cols if c not in names]
|
||
check(not missing, f"[cross-check-sql] {rel} 覆盖 {const} {len(code_cols)} 列"
|
||
f" via {how}" + ("" if not missing else f" 缺列={missing}"))
|
||
# 等价替代断言(四段式下把单向覆盖升级为双向契约一致,强度不降反升)
|
||
nocov = [c for c in names if c not in set(code_cols)]
|
||
check(not nocov, f"[cross-check-sql-nocov] {rel} fields 无代码未声明列"
|
||
f"(fields⊆{const})" + ("" if not nocov else f" 幽灵列={nocov}"))
|
||
check(list(code_cols) == list(names),
|
||
f"[cross-check-sql-order] {rel} {const} 顺序 == fields 声明顺序"
|
||
f"(columns_of 有序导出契约)")
|
||
declared = DECLARED_COL_COUNT.get(tname)
|
||
if declared is not None:
|
||
check(len(code_cols) == declared and len(names) == declared,
|
||
f"[cross-check-sql-count] {rel} 列数==宣称 {declared}"
|
||
f" -> code={len(code_cols)} fields={len(names)}")
|
||
|
||
# --- codes ---
|
||
codes = doc.get("codes") or []
|
||
seen_fields = set()
|
||
for c in codes:
|
||
ckeys = set(c.keys())
|
||
check(ckeys <= CODE_KEYS, f"[code-keys] {rel}#{c.get('field')} ⊆ {sorted(CODE_KEYS)}"
|
||
+ ("" if ckeys <= CODE_KEYS else f" 非白名单键={sorted(ckeys - CODE_KEYS)}"))
|
||
check(str(c.get("table") or "") and "." not in str(c.get("table")),
|
||
f"[code-table-no-dot] {rel}#{c.get('field')} table={c.get('table')}")
|
||
if str(c.get("table")) == "appcodes_kv":
|
||
check(str(c.get("cond") or "").startswith("parentid="),
|
||
f"[code-cond-parentid] {rel}#{c.get('field')} cond={c.get('cond')}")
|
||
cf = c.get("field")
|
||
check(cf not in seen_fields, f"[code-no-dup] {rel} field={cf} 不重复")
|
||
seen_fields.add(cf)
|
||
check(cf in names, f"[code-field-exists] {rel} field={cf} 在 fields 中")
|
||
|
||
# 全文无方言具体类型字样(type 值层面已断言,这里兜底扫 type 键)
|
||
dialect = [f.get("name") for f in fields
|
||
if re.search(r"(VARCHAR|BIGINT|DATETIME\(|TIMESTAMP\(|NVARCHAR|INT4|INT8)", str(f.get("type")))]
|
||
check(not dialect, f"[no-dialect-types] {rel} 无方言具体类型" + ("" if not dialect else f" 违规={dialect}"))
|
||
|
||
|
||
def main() -> int:
|
||
paths = []
|
||
for t in TABLES:
|
||
paths.append(os.path.join(SRC_DIR, t + ".json"))
|
||
for t in TABLES:
|
||
paths.append(os.path.join(MIRROR_DIR, t + ".json"))
|
||
|
||
print("== 自检落点(4 个路径,真源 + 镜像同一套 spec 断言)==")
|
||
for p in paths:
|
||
print(" - " + p)
|
||
print()
|
||
|
||
for rel in paths:
|
||
assert_spec(rel)
|
||
|
||
print("== 真源 vs 镜像 sha256 一致性断言 ==")
|
||
for t in TABLES:
|
||
src_rel = os.path.join(SRC_DIR, t + ".json")
|
||
mir_rel = os.path.join(MIRROR_DIR, t + ".json")
|
||
sp, mp = os.path.join(WS_ROOT, src_rel), os.path.join(WS_ROOT, mir_rel)
|
||
if os.path.isfile(sp) and os.path.isfile(mp):
|
||
hs, hm = sha256_of(sp), sha256_of(mp)
|
||
print(f" {t}: src={hs}")
|
||
print(f" {t}: mir={hm}")
|
||
check(hs == hm, f"[mirror-sha256-equal] {t} 真源与镜像字节级一致 -> {hs == hm}")
|
||
else:
|
||
check(False, f"[mirror-sha256-equal] {t} 文件缺失,无法比对")
|
||
print()
|
||
|
||
print("== 代码侧列名解析方式(形态 A/B 兼容诊断)==")
|
||
for t in TABLES:
|
||
cols, how = extract_code_columns(t, COLS_IN_CODE[t])
|
||
print(f" {COLS_IN_CODE[t]}: n={len(cols) if cols else 0} via {how}")
|
||
print()
|
||
|
||
passed = sum(1 for ok, _ in results if ok)
|
||
failed = [(line) for ok, line in results if not ok]
|
||
for ok, line in results:
|
||
print(("PASS " if ok else "FAIL ") + line)
|
||
print()
|
||
print(f"断言合计 {len(results)}:PASS {passed} / FAIL {len(failed)}")
|
||
if failed:
|
||
print("RESULT: FAIL")
|
||
return 1
|
||
print("RESULT: PASS")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|