#!/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())