#!/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/
.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()`` → `` = ""`` → 读唯一真源
``modules/world_sync/models/.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() → = ""。"""
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/.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())