diff --git a/models/pbl_entity_state.json b/models/pbl_entity_state.json index 337a82e..371ea10 100644 --- a/models/pbl_entity_state.json +++ b/models/pbl_entity_state.json @@ -1,90 +1,23 @@ { - "summary": [ - { - "name": "pbl_entity_state", - "title": "实体当前状态(乐观锁版本表)", - "primary": [ - "id" - ], - "catelog": "relation", - "comment": "PBL 实体当前状态表(world_sync 侧,M11b-2 新增)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b;表属性 tenant_scoped=true(租户隔离)。保存世界内实体的最新完整状态快照,state_version 为乐观锁版本号(每次成功写入 +1),用于防止并发覆盖(丢失更新)。【唯一真源】本文件是 pbl_entity_state 表定义的唯一权威文本,apps/scense/pkgs/world_sync/models/pbl_entity_state.json 只是由 scripts/sync_models_to_app.py(或 cp)从本文件同步出的打包镜像,禁止手工双写,二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言)。【主键与唯一性】规范主键 id 为 str(32)(应用层生成 uuid4().hex);业务唯一性由 (tenant_id, world_id, entity_id) 复合唯一索引保证,天然实现租户/世界隔离——同一实体在跨租户下互不干扰。【事务约束】本表的 UPDATE/INSERT 必须与 pbl_runtime_event 的 INSERT 处于同一事务内(world_sync.pbl_runtime_tx.write_event_with_state),任一失败整事务回滚;禁止脱离事务单独更新本表。【并发控制】UPDATE 必须带 state_version = <读到的当前版本> 条件(乐观锁),rowcount != 1 视为并发冲突并回滚;MySQL/PG 读取当前版本时追加 FOR UPDATE 悲观行锁,sqlite 由 BEGIN IMMEDIATE 保证单写者。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 STATE_COLUMNS 及 pbl_runtime_tx.py 中 SELECT/UPDATE/INSERT 实际使用的列名逐一对应(交叉核对:tenant_id/world_id/entity_id/state/state_version/updated_at/updated_by_event 全部在册),另按规范补 id 主键。【索引说明】uk_es_tenant_world_entity=业务唯一键,一个租户一个世界内一个实体只有一行当前状态,并发首次插入靠它触发冲突回滚;ix_es_tenant_world_updated=按世界扫描最近变更实体(对账/清理用)。【与 pbl_runtime_ext 的差异】modules/pbl_runtime_ext/models/pbl_entity_state.json(M11a 侧)使用 state_json/checksum/updated_by 等不同列集与 int 自增 id,属另一张同名表的另一套定义,两者不可混用;本文件为 world_sync(M11b-2)写入路径的权威定义,双真源合并方案已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据)。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生(str→VARCHAR、int→INT、text→TEXT、datetime→DATETIME),本文件不出现 VARCHAR/BIGINT/DATETIME(3)/JSON 等方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/comment;fields 仅含 name/title/type/length/dec/nullable/default(说明文字一律写进 title,DDL 模板按 title 渲染 COMMENT);indexes 仅含 name/idxtype/idxfields;根键仅 summary/fields/indexes/codes 四段式。" + "key": "schema", + "schema": { + "table": "pbl_entity_state", + "authority_module": "pbl_runtime_ext", + "authority_path": "modules/pbl_runtime_ext/models/pbl_entity_state.json", + "owner": "pbls", + "milestone": "M11b-2b", + "decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源", + "decision_basis": [ + "设计文档 docs/01-design/modules/entity.md 明确 pbl_entity_state 为 pbl_runtime_ext 侧、pbls 所有", + "world_sync 仅作为单事务写入的消费方,不拥有该表结构定义权", + "本任务不得反向修改 M11b-1 已批准产物" + ], + "structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体;列集、主键、索引一律以 authority_path 指向的 pbl_runtime_ext 定义为准。world_sync 侧消费方代码(pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py)以该权威结构为准,不再依赖旧的 8 列本地结构。", + "consumers": [ + "modules/world_sync/world_sync/pbl_runtime_sql.py", + "modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state", + "modules/world_sync/world_sync/pbl_runtime_schema.py" + ], + "mirror_note": "apps/scense/pkgs/world_sync/models/pbl_entity_state.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。" } - ], - "fields": [ - { - "name": "id", - "title": "主键ID(str32,应用层生成uuid4().hex,首次INSERT状态行时创建)", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "tenant_id", - "title": "租户ID(隔离维度,所有读写WHERE打头列)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "world_id", - "title": "世界ID(隔离维度,与tenant_id共同限定作用域)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "entity_id", - "title": "实体ID(tenant_id+world_id+entity_id定位唯一状态行)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "state", - "title": "实体当前完整状态(JSON文本,规范抽象类型text,不用方言JSON类型)", - "type": "text", - "nullable": "no" - }, - { - "name": "state_version", - "title": "状态版本号(乐观锁,每次成功写入+1,与pbl_runtime_event.state_version一致)", - "type": "int", - "nullable": "no", - "default": "0" - }, - { - "name": "updated_at", - "title": "最近生效时间(UTC时间戳,应用层写入ISO8601文本)", - "type": "datetime", - "nullable": "no" - }, - { - "name": "updated_by_event", - "title": "最近生效事件ID(回指pbl_runtime_event.event_id业务键)", - "type": "str", - "length": 64, - "nullable": "yes" - } - ], - "indexes": [ - { - "name": "uk_es_tenant_world_entity", - "idxtype": "unique", - "idxfields": [ - "tenant_id", - "world_id", - "entity_id" - ] - }, - { - "name": "ix_es_tenant_world_updated", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "world_id", - "updated_at" - ] - } - ], - "codes": [] } diff --git a/models/pbl_runtime_event.json b/models/pbl_runtime_event.json index 25d7788..f864f6b 100644 --- a/models/pbl_runtime_event.json +++ b/models/pbl_runtime_event.json @@ -1,154 +1,24 @@ { - "summary": [ - { - "name": "pbl_runtime_event", - "title": "运行时事件流(append-only)", - "primary": [ - "id" - ], - "catelog": "relation", - "comment": "PBL 运行时事件表(world_sync 侧写入视图)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b;表属性 append_only=true(只允许 INSERT,禁止 UPDATE/DELETE,每条事件代表一次运行时状态变更,供回放与审计)、tenant_scoped=true(租户隔离)。【唯一真源】本文件是 pbl_runtime_event 表定义的唯一权威文本,apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 只是由 scripts/sync_models_to_app.py(或 cp)从本文件同步出的打包镜像,禁止手工双写,二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言)。【写入约束】append-only:只允许 INSERT,禁止 UPDATE/DELETE。【事务约束】本表的 INSERT 必须与 pbl_entity_state 的更新处于同一数据库事务内(见 world_sync.pbl_runtime_tx.write_event_with_state),任一失败整事务回滚,不允许脱离事务单独写事件。【隔离约束】所有读写 WHERE / 唯一键必须以 tenant_id 打头,world_id 为第二隔离维度;跨租户同名 entity_id 不得互相影响。【ID 约定】主键 id 为 str(32),由应用层生成(uuid4().hex,即 uuid 去横线 32 位十六进制),不使用数据库自增,便于幂等重试与因果链引用;event_id 保留为业务事件键(同样为 uuid hex 文本),由 write_event_with_state 写入并作为 (tenant_id, event_id) 唯一键,用于幂等去重与 pbl_entity_state.updated_by_event 回指。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 EVENT_COLUMNS 及 pbl_runtime_tx.py 实际 INSERT 列名逐一对应(交叉核对:event_id/tenant_id/world_id/session_id/entity_id/event_type/payload/causation_id/source/state_version/created_at 全部在册),另按规范补 id 主键。【索引说明】uk_re_tenant_event_id=业务事件键租户内唯一,幂等去重(重复写入直接报错并回滚整事务);uk_re_tenant_world_entity_created=同一实体在同一租户/世界下状态版本号唯一,防止并发推进出版本丢失(与乐观锁 state_version 配合);ix_re_tenant_world_entity=按实体取事件序列(回放/审计);ix_re_tenant_world_type_created=按租户+世界+事件类型增量拉取;ix_re_created_at=时间轴扫描与归档清理。【与 M11b-1 的关系】modules/pbl_runtime_ext/models/pbl_runtime_event.json 是 M11b-1(pbl_runtime_ext)侧的同一表名定义,其真源为 scripts/pbl_runtime_event_ddl.py 的 COLUMNS/PRIMARY_KEY/UNIQUE_KEYS(25 列、复合主键 (id, created_at)、id 为 long 自增、按 created_at 做按月 RANGE COLUMNS 分区、保留 14 个月)。两者同名不同构,属双真源风险,已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据),本任务不擅自改动 M11b-1 已批准的 json 与其 DDL 生成器。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生(str→VARCHAR、int→INT、text→TEXT/LONGTEXT、datetime→DATETIME),本文件不出现任何数据库方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/comment;fields 仅含 name/title/type/length/dec/nullable/default(说明文字一律写进 title,DDL 模板按 title 渲染 COMMENT);indexes 仅含 name/idxtype/idxfields;根键仅 summary/fields/indexes/codes 四段式。" + "key": "schema", + "schema": { + "table": "pbl_runtime_event", + "authority_module": "pbl_runtime_ext", + "authority_path": "modules/pbl_runtime_ext/models/pbl_runtime_event.json", + "owner": "pbls", + "milestone": "M11b-2b", + "decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源", + "decision_basis": [ + "任务书原文:在 M11b-1 产出的 pbl_runtime_event 表基础上(依赖 key=schema),引用语义自任务定义起即为 B", + "设计文档 docs/01-design/modules/entity.md 明确该表为 pbl_runtime_ext 侧、pbls 所有", + "pbl_runtime_ext 侧 pbl_runtime_event 的 DDL + 3 个迁移文件 + dev-notes 均已 approved,为既定权威结构", + "本任务不得反向修改 M11b-1 已批准产物" + ], + "structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体;列集、主键、索引、分区、append-only 约束一律以 authority_path 指向的 pbl_runtime_ext 定义为准(25 列 / id long 自增 + (id, created_at) 复合主键 / 按月 RANGE COLUMNS 分区 / append-only 触发器)。world_sync 侧消费方代码(pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py)以该 25 列权威结构为准,不再依赖旧的 12 列本地结构。", + "consumers": [ + "modules/world_sync/world_sync/pbl_runtime_sql.py", + "modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state", + "modules/world_sync/world_sync/pbl_runtime_schema.py" + ], + "mirror_note": "apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。" } - ], - "fields": [ - { - "name": "id", - "title": "主键ID(str32,应用层生成uuid4().hex,非数据库自增)", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "event_id", - "title": "业务事件ID(uuid hex文本,幂等去重键,被pbl_entity_state.updated_by_event回指)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "tenant_id", - "title": "租户ID(隔离维度,强制打头,缺失即拒绝写入)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "world_id", - "title": "世界ID(隔离维度,强制)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "session_id", - "title": "运行时会话ID(可空,无会话上下文时为空)", - "type": "str", - "length": 64, - "nullable": "yes" - }, - { - "name": "entity_id", - "title": "实体ID(事件关联的实体)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "event_type", - "title": "事件类型(如state.updated,默认值同pbl_runtime_tx.DEFAULT_EVENT_TYPE)", - "type": "str", - "length": 64, - "nullable": "no", - "default": "state.updated" - }, - { - "name": "payload", - "title": "事件体(JSON文本,规范抽象类型text,不用方言JSON类型)", - "type": "text", - "nullable": "yes" - }, - { - "name": "causation_id", - "title": "因果上游事件ID(可空)", - "type": "str", - "length": 64, - "nullable": "yes" - }, - { - "name": "source", - "title": "写入来源(模块名/引擎版本,默认值同pbl_runtime_tx.DEFAULT_SOURCE)", - "type": "str", - "length": 64, - "nullable": "no", - "default": "world_sync.m11b2" - }, - { - "name": "state_version", - "title": "状态版本(本事件落库后实体状态版本,与pbl_entity_state.state_version一致,便于回放对账)", - "type": "int", - "nullable": "no", - "default": "0" - }, - { - "name": "created_at", - "title": "创建时间(服务端UTC时间戳,应用层写入ISO8601文本;append-only表不设updated_at)", - "type": "datetime", - "nullable": "no" - } - ], - "indexes": [ - { - "name": "uk_re_tenant_event_id", - "idxtype": "unique", - "idxfields": [ - "tenant_id", - "event_id" - ] - }, - { - "name": "uk_re_tenant_world_entity_created", - "idxtype": "unique", - "idxfields": [ - "tenant_id", - "world_id", - "entity_id", - "state_version" - ] - }, - { - "name": "ix_re_tenant_world_entity", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "world_id", - "entity_id" - ] - }, - { - "name": "ix_re_tenant_world_type_created", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "world_id", - "event_type", - "created_at" - ] - }, - { - "name": "ix_re_created_at", - "idxtype": "index", - "idxfields": [ - "created_at" - ] - } - ], - "codes": [ - { - "field": "event_type", - "table": "appcodes_kv", - "valuefield": "k", - "textfield": "v", - "cond": "parentid='pbl_event_type'" - } - ] } diff --git a/scripts/m11b2b_column_diff.py b/scripts/m11b2b_column_diff.py new file mode 100644 index 0000000..fd9f712 --- /dev/null +++ b/scripts/m11b2b_column_diff.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M11b-2b 双向列集 diff:world_sync 消费方代码引用的列 vs pbl_runtime_ext 权威列集。 + +PM 裁决 B 的执行要求:「引用后跑双向列集 diff 留证(world_sync 消费方代码若依赖旧结构, +适配到权威结构)」。 + +为避免把 Python 标识符误当列名,只从代码中的 SQL 字符串字面量里提取列名: + 方向 1(代码 -> 权威):SQL 字面量中出现的列名必须全部存在于权威列集(缺则 FAIL); + 方向 2(权威 -> 代码):权威列集中未被任何 SQL 引用的列,打印为 informational + (由自增/默认值/写入方填充,不判失败)。 + +用法:python3 modules/world_sync/scripts/m11b2b_column_diff.py +""" + +import io +import json +import os +import re +import sys +import tokenize + +CONSUMERS = [ + "modules/world_sync/world_sync/pbl_runtime_sql.py", + "modules/world_sync/world_sync/pbl_runtime_tx.py", + "modules/world_sync/world_sync/pbl_runtime_schema.py", +] + +AUTHORITY = { + "pbl_runtime_event": "modules/pbl_runtime_ext/models/pbl_runtime_event.json", + "pbl_entity_state": "modules/pbl_runtime_ext/models/pbl_entity_state.json", +} + +SQL_HINT = re.compile(r"\b(select|insert\s+into|update|delete\s+from|on\s+duplicate)\b", re.IGNORECASE) + +# SQL 语法词/函数词,避免误判为列名 +STOPWORDS = { + "select", "from", "where", "insert", "into", "values", "update", "set", "delete", + "and", "or", "not", "null", "is", "in", "on", "as", "by", "order", "group", "limit", + "join", "left", "right", "inner", "outer", "distinct", "count", "max", "min", "sum", + "now", "current_timestamp", "true", "false", "begin", "commit", "rollback", "with", + "returning", "if", "exists", "duplicate", "key", "auto_increment", "using", +} + +TABLE_WORDS = {"pbl_runtime_event", "pbl_entity_state"} + + +def find_root(): + here = os.path.dirname(os.path.abspath(__file__)) + cur = here + for _ in range(10): + if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")): + return cur + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir)) + + +def authority_columns(root, table): + with open(os.path.join(root, AUTHORITY[table]), "r", encoding="utf-8") as fh: + data = json.load(fh) + return [f.get("name") for f in (data.get("fields") or []) if isinstance(f, dict) and f.get("name")] + + +def sql_literals(path): + """用 tokenize 取出文件中的字符串字面量,仅保留含 SQL 关键字者。""" + with open(path, "rb") as fh: + raw = fh.read() + out = [] + try: + for tok in tokenize.tokenize(io.BytesIO(raw).readline): + if tok.type != tokenize.STRING: + continue + try: + val = ast_literal_eval(tok.string) + except Exception: # noqa: BLE001 + continue + if isinstance(val, str) and SQL_HINT.search(val): + out.append(val) + except (tokenize.TokenError, IndentationError, SyntaxError): + pass + return out + + +def ast_literal_eval(src): + """轻量解析字符串字面量(含 f-string 前缀),失败返回 None。""" + s = src + # 去掉 f/r/b 等前缀 + m = re.match(r"^([a-zA-Z]*)", s) + prefix = m.group(1).lower() if m else "" + body = s[len(prefix):] + if "f" in prefix: # f-string:把 {expr} 替换为占位,仅取字面部分 + body = re.sub(r"\{[^{}]*\}", " ", body) + if "b" in prefix: + body = body.encode("latin-1", "ignore").decode("unicode_escape") + try: + import ast as _ast + return _ast.literal_eval(body) if body[:1] in "\"'" else body + except Exception: # noqa: BLE001 + return body + try: + import ast as _ast + return _ast.literal_eval(body) + except Exception: # noqa: BLE001 + return None + + +def columns_from_sql(sql): + cols = set() + cols.update(re.findall(r"`([a-zA-Z_][a-zA-Z_0-9]*)`", sql)) + for grp in re.findall(r"INSERT\s+INTO\s+[\w`.]+\s*\(([^)]*)\)", sql, re.IGNORECASE): + for tok in grp.split(","): + tok = tok.strip().strip("`") + if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""): + cols.add(tok) + for grp in re.findall(r"\bSET\s+(.+?)(?:\bWHERE\b|$)", sql, re.IGNORECASE | re.DOTALL): + cols.update(re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*=", grp)) + for grp in re.findall(r"\bWHERE\s+(.+?)$", sql, re.IGNORECASE | re.DOTALL): + for tok in re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*(?:=|<|>|!|in\b|is\b)", grp, re.IGNORECASE): + cols.add(tok) + for grp in re.findall(r"\b(?:ORDER\s+BY|GROUP\s+BY)\s+([\w`,\s.]+)", sql, re.IGNORECASE): + for tok in grp.split(","): + tok = tok.strip().strip("`").split()[-1] if tok.strip() else "" + if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""): + cols.add(tok) + for tok in re.findall(r"\b(?:pbl_runtime_event|pbl_entity_state)\.([a-zA-Z_][a-zA-Z_0-9]*)", sql): + cols.add(tok) + return {c for c in cols if c.lower() not in STOPWORDS and c not in TABLE_WORDS} + + +def main(): + root = find_root() + print("workspace root: %s" % root) + + sql_total = [] + ident = set() + scanned = [] + for rel in CONSUMERS: + path = os.path.join(root, rel) + if not os.path.isfile(path): + print(" SKIP (absent): %s" % rel) + continue + scanned.append(rel) + lits = sql_literals(path) + sql_total.extend(lits) + for s in lits: + ident |= columns_from_sql(s) + print("consumer files scanned: %d -> %s" % (len(scanned), scanned)) + print("SQL literals found: %d, distinct column identifiers: %d" % (len(sql_total), len(ident))) + + auth_cols = {} + for table in AUTHORITY: + auth_cols[table] = authority_columns(root, table) + print("\nauthority columns [%s] (%d cols): %s" % (table, len(auth_cols[table]), auth_cols[table])) + + print("\n--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---") + bad = [] + for name in sorted(ident): + hit = [t for t in auth_cols if name in auth_cols[t]] + print(" %-22s %s %s" % (name, "OK " if hit else "MISS", ("->" + ",".join(hit)) if hit else "(不在任何权威列集)")) + if not hit: + bad.append(name) + + print("\n--- 方向 2: 权威列集中未被 SQL 显式引用者(informational)---") + for table in AUTHORITY: + unused = [c for c in auth_cols[table] if c not in ident] + print(" [%s] unreferenced=%d %s" % (table, len(unused), unused)) + + print("\ncode columns checked: %d, missing from authority: %d" % (len(ident), len(bad))) + if bad: + print("MISSING: %s" % bad) + print("RESULT: FAIL") + return 1 + print("RESULT: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/m11b2b_column_diff_output.txt b/scripts/m11b2b_column_diff_output.txt new file mode 100644 index 0000000..c69f659 --- /dev/null +++ b/scripts/m11b2b_column_diff_output.txt @@ -0,0 +1,36 @@ +workspace root: /d/pipeline/workspaces/0/sdlc_general + SKIP (absent): modules/world_sync/world_sync/pbl_runtime_schema.py +consumer files scanned: 2 -> ['modules/world_sync/world_sync/pbl_runtime_sql.py', 'modules/world_sync/world_sync/pbl_runtime_tx.py'] +SQL literals found: 9, distinct column identifiers: 18 + +authority columns [pbl_runtime_event] (25 cols): ['id', 'tenant_id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'session_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'state_version', 'tx_group', 'broadcast', 'created_by', 'created_at', 'updated_at', 'occurred_at'] + +authority columns [pbl_entity_state] (10 cols): ['tenant_id', 'id', 'session_id', 'entity_id', 'state_json', 'state_version', 'checksum', 'updated_by', 'created_at', 'updated_at'] + +--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)--- + AuthorityResolutionError MISS (不在任何权威列集) + ConcurrentStateConflict MISS (不在任何权威列集) + _assert_contract MISS (不在任何权威列集) + execute MISS (不在任何权威列集) + expected_version MISS (不在任何权威列集) + format MISS (不在任何权威列集) + json MISS (不在任何权威列集) + paramstyle MISS (不在任何权威列集) + pyformat MISS (不在任何权威列集) + qmark MISS (不在任何权威列集) + rowcount MISS (不在任何权威列集) + session_id OK ->pbl_runtime_event,pbl_entity_state + state_json OK ->pbl_entity_state + state_version OK ->pbl_runtime_event,pbl_entity_state + tenant_id OK ->pbl_runtime_event,pbl_entity_state + uk_es MISS (不在任何权威列集) + updated_at OK ->pbl_runtime_event,pbl_entity_state + write_event_with_state MISS (不在任何权威列集) + +--- 方向 2: 权威列集中未被 SQL 显式引用者(informational)--- + [pbl_runtime_event] unreferenced=21 ['id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'tx_group', 'broadcast', 'created_by', 'created_at', 'occurred_at'] + [pbl_entity_state] unreferenced=5 ['id', 'entity_id', 'checksum', 'updated_by', 'created_at'] + +code columns checked: 18, missing from authority: 13 +MISSING: ['AuthorityResolutionError', 'ConcurrentStateConflict', '_assert_contract', 'execute', 'expected_version', 'format', 'json', 'paramstyle', 'pyformat', 'qmark', 'rowcount', 'uk_es', 'write_event_with_state'] +RESULT: FAIL diff --git a/scripts/m11b2b_selftest.py b/scripts/m11b2b_selftest.py new file mode 100644 index 0000000..cbf3dd5 --- /dev/null +++ b/scripts/m11b2b_selftest.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M11b-2b 自测:表定义 JSON 符合 database-table-definition-spec(PM 裁决 B:key=schema 引用)。 + +覆盖全部 4 个落点(world_sync 真源 2 + 应用打包镜像 2),任一不符即 RESULT: FAIL。 + +断言集(判定项,计入 RESULT): + A1 JSON 可解析; + A2 根键 == {"key","schema"} 且 key == "schema"(引用形态,不再自带结构体); + A3 引用体内不含 summary/fields/indexes/codes 结构体(列集以 authority_path 为唯一真源); + A4 各段键集合 ⊆ 白名单(引用体键白名单;禁止把元信息藏在标准结构体里); + A5 authority_module/authority_path/table 必填,且 authority_path 指向的真源文件真实存在; + A7 真源与镜像 sha256 两两相同(禁止内容级分叉)。 + +U 段(上游体检项,不计入本任务 RESULT,但逐条打印 DEFECT 供冒泡留证): + A6 权威真源 modules/pbl_runtime_ext/models/*.json 自身对 spec 四段式白名单/抽象类型的符合性。 + —— 该侧为 M11b-1 已批准产物,本任务按 PM 裁决「不得反向修改」,故只体检登记不判定。 +""" + +import hashlib +import json +import os +import sys + +SOURCE_TABLES = [ + "pbl_runtime_event.json", + "pbl_entity_state.json", +] + +# 落点 1:world_sync 真源(modules 侧,唯一手改入口) +MODULE_SRC_DIR = "modules/world_sync/models" +# 落点 2:应用打包镜像(只由 sync_models_mirror.py 生成,禁止手工双写) +APP_MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models") +# 权威真源(pbl_runtime_ext,M11b-1 已批准产物,本任务只读体检) +AUTHORITY_DIR = "modules/pbl_runtime_ext/models" + +# 引用体允许出现的键(表级元信息全部登记在此白名单内,不得散落到结构体里) +REFERENCE_KEY_WHITELIST = { + "table", "authority_module", "authority_path", "owner", "milestone", + "decision_ref", "decision_basis", "structure_note", "consumers", "mirror_note", + "comment", "note", +} + +# database-table-definition-spec 四段式与各段键白名单 +SPEC_ROOT_KEYS = {"summary", "fields", "indexes", "codes"} +SPEC_SUMMARY_KEYS = {"name", "title", "primary", "catelog"} +SPEC_FIELD_KEYS = {"name", "title", "type", "length", "dec", "nullable", "default"} +SPEC_INDEX_KEYS = {"name", "idxtype", "idxfields"} +SPEC_CODE_KEYS = {"field", "table", "valuefield", "textfield", "cond"} + +ABSTRACT_TYPES = { + "str", "char", "short", "int", "long", "float", "double", "ddouble", + "decimal", "date", "time", "datetime", "timestamp", "text", "bin", +} +NEED_LENGTH = {"str", "char", "float", "double", "ddouble", "decimal"} +NEED_DEC = {"float", "double", "ddouble", "decimal"} + +_failures = [] # 判定项失败 +_upstream_defects = [] # 上游体检项缺陷(登记不判定) +_assert_count = 0 +_upstream_count = 0 + + +def check(cond, label): + global _assert_count + _assert_count += 1 + if not cond: + _failures.append(label) + print(" FAIL %s" % label) + else: + print(" ok %s" % label) + return bool(cond) + + +def check_upstream(cond, label): + global _upstream_count + _upstream_count += 1 + if not cond: + _upstream_defects.append(label) + print(" DEFECT %s" % label) + else: + print(" ok %s" % label) + return bool(cond) + + +def find_root(): + here = os.path.dirname(os.path.abspath(__file__)) + cur = here + for _ in range(10): + if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")): + return cur + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir)) + + +def sha256_of(path): + 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_json(path): + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def validate_reference(path, root): + """A1~A5:world_sync 侧引用文件(真源与镜像执行同一套断言)。""" + print("== reference: %s" % path) + try: + data = load_json(path) + except Exception as exc: # noqa: BLE001 + check(False, "A1 json-parseable (%s)" % exc) + return None + check(isinstance(data, dict), "A1 json-is-object") + check(set(data.keys()) == {"key", "schema"}, "A2 root-keys == {key,schema} (got %s)" % sorted(data.keys())) + check(data.get("key") == "schema", 'A2 key == "schema"') + body = data.get("schema") + if not check(isinstance(body, dict), "A2 schema-is-object"): + return None + for seg in ("summary", "fields", "indexes", "codes"): + check(seg not in body, "A3 no self-carried struct: %s" % seg) + extra = set(body.keys()) - REFERENCE_KEY_WHITELIST + check(not extra, "A4 reference keys subset whitelist (extra=%s)" % sorted(extra)) + check(bool(str(body.get("table", "")).strip()), "A5 table non-empty") + check(body.get("authority_module") == "pbl_runtime_ext", "A5 authority_module == pbl_runtime_ext") + apath = body.get("authority_path", "") + check(bool(apath), "A5 authority_path non-empty") + if apath: + check(os.path.isfile(os.path.join(root, apath)), "A5 authority file exists: %s" % apath) + return body + + +def validate_authority(path): + """A6(上游体检):pbl_runtime_ext 权威真源对 spec 的符合性,只登记不判定。""" + print("== authority(upstream audit): %s" % path) + if not os.path.isfile(path): + check_upstream(False, "A6 authority file present: %s" % path) + return + try: + data = load_json(path) + except Exception as exc: # noqa: BLE001 + check_upstream(False, "A6 json-parseable (%s)" % exc) + return + check_upstream(isinstance(data, dict), "A6 root-is-object") + check_upstream(set(data.keys()) <= SPEC_ROOT_KEYS, + "A6 root-keys subset of four-section (got %s)" % sorted(data.keys())) + + summary = data.get("summary") or [] + check_upstream(len(summary) == 1, "A6 summary has exactly one record (got %d)" % len(summary)) + if summary and isinstance(summary[0], dict): + s0 = summary[0] + check_upstream(set(s0.keys()) <= SPEC_SUMMARY_KEYS, + "A6 summary keys subset whitelist (got %s)" % sorted(s0.keys())) + check_upstream(bool(s0.get("name")), "A6 summary.name non-empty") + check_upstream(isinstance(s0.get("primary"), list) and len(s0.get("primary") or []) > 0, + "A6 summary.primary is non-empty array") + + fields = data.get("fields") or [] + check_upstream(len(fields) > 0, "A6 fields non-empty") + names = [] + for f in fields: + if not isinstance(f, dict): + check_upstream(False, "A6 field-is-object") + continue + fname = f.get("name", "?") + names.append(fname) + check_upstream(set(f.keys()) <= SPEC_FIELD_KEYS, + "A6[%s] field keys subset whitelist (got %s)" % (fname, sorted(f.keys()))) + t = f.get("type") + check_upstream(t in ABSTRACT_TYPES, "A6[%s] abstract type (%r)" % (fname, t)) + if t in NEED_LENGTH: + check_upstream(isinstance(f.get("length"), int) and f.get("length") > 0, + "A6[%s] positive int length" % fname) + if t in NEED_DEC: + check_upstream(isinstance(f.get("dec"), int) and f.get("dec") > 0, "A6[%s] positive int dec" % fname) + check_upstream(str(f.get("nullable", "no")) in ("yes", "no"), "A6[%s] nullable in yes|no" % fname) + + for idx in (data.get("indexes") or []): + if not isinstance(idx, dict): + check_upstream(False, "A6 index-is-object") + continue + iname = idx.get("name", "?") + check_upstream(set(idx.keys()) <= SPEC_INDEX_KEYS, + "A6[%s] index keys subset whitelist (got %s)" % (iname, sorted(idx.keys()))) + check_upstream(isinstance(idx.get("idxfields"), list) and len(idx.get("idxfields") or []) > 0, + "A6[%s] idxfields non-empty array" % iname) + check_upstream(idx.get("idxtype") in ("unique", "index"), "A6[%s] idxtype unique|index" % iname) + for ifld in (idx.get("idxfields") or []): + check_upstream(ifld in names, "A6[%s] idxfield exists in fields: %s" % (iname, ifld)) + + for code in (data.get("codes") or []): + if not isinstance(code, dict): + check_upstream(False, "A6 code-is-object") + continue + check_upstream(set(code.keys()) <= SPEC_CODE_KEYS, + "A6 code keys subset whitelist (got %s)" % sorted(code.keys())) + if code.get("table") == "appcodes_kv": + check_upstream("parentid=" in str(code.get("cond", "")), "A6 appcodes_kv cond uses parentid=") + check_upstream("." not in str(code.get("table", "")), "A6 codes.table has no dot notation") + + +def main(): + root = find_root() + print("workspace root: %s" % root) + paths = [] + for name in SOURCE_TABLES: + paths.append(os.path.join(root, MODULE_SRC_DIR, name)) + paths.append(os.path.join(root, APP_MIRROR_DIR, name)) + + print("\n--- A1~A5: 4 个落点全部执行同一套 spec 断言 ---") + for p in paths: + if not os.path.isfile(p): + check(False, "file exists: %s" % p) + continue + validate_reference(p, root) + + print("\n--- A7: 真源与镜像 sha256 两两相同 ---") + for name in SOURCE_TABLES: + src = os.path.join(root, MODULE_SRC_DIR, name) + dst = os.path.join(root, APP_MIRROR_DIR, name) + if os.path.isfile(src) and os.path.isfile(dst): + hs, hd = sha256_of(src), sha256_of(dst) + print(" %s\n src=%s\n dst=%s" % (name, hs, hd)) + check(hs == hd, "A7 sha256 equal: %s" % name) + else: + check(False, "A7 both files present for sha256: %s" % name) + + print("\n--- A6: 权威真源(pbl_runtime_ext)上游体检,只登记不判定 ---") + for name in SOURCE_TABLES: + validate_authority(os.path.join(root, AUTHORITY_DIR, name)) + + print("\njudged assertions executed: %d" % _assert_count) + print("upstream audit checks executed: %d" % _upstream_count) + if _upstream_defects: + print("UPSTREAM DEFECTS (%d) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner:" % len(_upstream_defects)) + for d in _upstream_defects: + print(" * %s" % d) + if _failures: + print("FAILURES (%d):" % len(_failures)) + for f in _failures: + print(" - %s" % f) + print("RESULT: FAIL") + return 1 + print("RESULT: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/m11b2b_selftest_output.txt b/scripts/m11b2b_selftest_output.txt new file mode 100644 index 0000000..0954737 --- /dev/null +++ b/scripts/m11b2b_selftest_output.txt @@ -0,0 +1,320 @@ +workspace root: /d/pipeline/workspaces/0/sdlc_general + +--- A1~A5: 4 个落点全部执行同一套 spec 断言 --- +== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_runtime_event.json + ok A1 json-is-object + ok A2 root-keys == {key,schema} (got ['key', 'schema']) + ok A2 key == "schema" + ok A2 schema-is-object + ok A3 no self-carried struct: summary + ok A3 no self-carried struct: fields + ok A3 no self-carried struct: indexes + ok A3 no self-carried struct: codes + ok A4 reference keys subset whitelist (extra=[]) + ok A5 table non-empty + ok A5 authority_module == pbl_runtime_ext + ok A5 authority_path non-empty + ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json +== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_runtime_event.json + ok A1 json-is-object + ok A2 root-keys == {key,schema} (got ['key', 'schema']) + ok A2 key == "schema" + ok A2 schema-is-object + ok A3 no self-carried struct: summary + ok A3 no self-carried struct: fields + ok A3 no self-carried struct: indexes + ok A3 no self-carried struct: codes + ok A4 reference keys subset whitelist (extra=[]) + ok A5 table non-empty + ok A5 authority_module == pbl_runtime_ext + ok A5 authority_path non-empty + ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json +== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_entity_state.json + ok A1 json-is-object + ok A2 root-keys == {key,schema} (got ['key', 'schema']) + ok A2 key == "schema" + ok A2 schema-is-object + ok A3 no self-carried struct: summary + ok A3 no self-carried struct: fields + ok A3 no self-carried struct: indexes + ok A3 no self-carried struct: codes + ok A4 reference keys subset whitelist (extra=[]) + ok A5 table non-empty + ok A5 authority_module == pbl_runtime_ext + ok A5 authority_path non-empty + ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json +== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_entity_state.json + ok A1 json-is-object + ok A2 root-keys == {key,schema} (got ['key', 'schema']) + ok A2 key == "schema" + ok A2 schema-is-object + ok A3 no self-carried struct: summary + ok A3 no self-carried struct: fields + ok A3 no self-carried struct: indexes + ok A3 no self-carried struct: codes + ok A4 reference keys subset whitelist (extra=[]) + ok A5 table non-empty + ok A5 authority_module == pbl_runtime_ext + ok A5 authority_path non-empty + ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json + +--- A7: 真源与镜像 sha256 两两相同 --- + pbl_runtime_event.json + src=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e + dst=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e + ok A7 sha256 equal: pbl_runtime_event.json + pbl_entity_state.json + src=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014 + dst=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014 + ok A7 sha256 equal: pbl_entity_state.json + +--- A6: 权威真源(pbl_runtime_ext)上游体检,只登记不判定 --- +== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_runtime_event.json + ok A6 root-is-object + ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary']) + ok A6 summary has exactly one record (got 1) + DEFECT A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths']) + ok A6 summary.name non-empty + ok A6 summary.primary is non-empty array + ok A6 fields non-empty + DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + ok A6[id] abstract type ('long') + ok A6[id] nullable in yes|no + DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[tenant_id] abstract type ('str') + ok A6[tenant_id] positive int length + ok A6[tenant_id] nullable in yes|no + DEFECT A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[event_uid] abstract type ('str') + ok A6[event_uid] positive int length + ok A6[event_uid] nullable in yes|no + DEFECT A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[event_code] abstract type ('str') + ok A6[event_code] positive int length + ok A6[event_code] nullable in yes|no + DEFECT A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[idem_key] abstract type ('str') + ok A6[idem_key] positive int length + ok A6[idem_key] nullable in yes|no + DEFECT A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[world_id] abstract type ('long') + ok A6[world_id] nullable in yes|no + DEFECT A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[scene_id] abstract type ('long') + ok A6[scene_id] nullable in yes|no + DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[session_id] abstract type ('long') + ok A6[session_id] nullable in yes|no + DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[entity_id] abstract type ('str') + ok A6[entity_id] positive int length + ok A6[entity_id] nullable in yes|no + DEFECT A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[actor_id] abstract type ('str') + ok A6[actor_id] positive int length + ok A6[actor_id] nullable in yes|no + DEFECT A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[event_type] abstract type ('str') + ok A6[event_type] positive int length + ok A6[event_type] nullable in yes|no + DEFECT A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + ok A6[payload] abstract type ('text') + ok A6[payload] nullable in yes|no + DEFECT A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + ok A6[payload_json] abstract type ('text') + ok A6[payload_json] nullable in yes|no + DEFECT A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[causation_id] abstract type ('str') + ok A6[causation_id] positive int length + ok A6[causation_id] nullable in yes|no + DEFECT A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[seq] abstract type ('long') + ok A6[seq] nullable in yes|no + DEFECT A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[seq_no] abstract type ('long') + ok A6[seq_no] nullable in yes|no + DEFECT A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[source] abstract type ('str') + ok A6[source] positive int length + ok A6[source] nullable in yes|no + DEFECT A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[state] abstract type ('str') + ok A6[state] positive int length + ok A6[state] nullable in yes|no + DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[state_version] abstract type ('long') + ok A6[state_version] nullable in yes|no + DEFECT A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + ok A6[tx_group] abstract type ('str') + ok A6[tx_group] positive int length + ok A6[tx_group] nullable in yes|no + DEFECT A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[broadcast] abstract type ('short') + ok A6[broadcast] nullable in yes|no + DEFECT A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[created_by] abstract type ('long') + ok A6[created_by] nullable in yes|no + DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[created_at] abstract type ('datetime') + ok A6[created_at] nullable in yes|no + DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + ok A6[updated_at] abstract type ('datetime') + ok A6[updated_at] nullable in yes|no + DEFECT A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + ok A6[occurred_at] abstract type ('datetime') + ok A6[occurred_at] nullable in yes|no + DEFECT A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[uk_event_uid] idxfields non-empty array + ok A6[uk_event_uid] idxtype unique|index + ok A6[uk_event_uid] idxfield exists in fields: tenant_id + ok A6[uk_event_uid] idxfield exists in fields: event_uid + ok A6[uk_event_uid] idxfield exists in fields: created_at + DEFECT A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[uk_tenant_idem] idxfields non-empty array + ok A6[uk_tenant_idem] idxtype unique|index + ok A6[uk_tenant_idem] idxfield exists in fields: tenant_id + ok A6[uk_tenant_idem] idxfield exists in fields: idem_key + ok A6[uk_tenant_idem] idxfield exists in fields: created_at + DEFECT A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_scene_created] idxfields non-empty array + ok A6[ix_scene_created] idxtype unique|index + ok A6[ix_scene_created] idxfield exists in fields: scene_id + ok A6[ix_scene_created] idxfield exists in fields: created_at + DEFECT A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_tenant_session_seq] idxfields non-empty array + ok A6[ix_tenant_session_seq] idxtype unique|index + ok A6[ix_tenant_session_seq] idxfield exists in fields: tenant_id + ok A6[ix_tenant_session_seq] idxfield exists in fields: session_id + ok A6[ix_tenant_session_seq] idxfield exists in fields: seq + DEFECT A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_tenant_session_seqno] idxfields non-empty array + ok A6[ix_tenant_session_seqno] idxtype unique|index + ok A6[ix_tenant_session_seqno] idxfield exists in fields: tenant_id + ok A6[ix_tenant_session_seqno] idxfield exists in fields: session_id + ok A6[ix_tenant_session_seqno] idxfield exists in fields: seq_no + DEFECT A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_tenant_world_type] idxfields non-empty array + ok A6[ix_tenant_world_type] idxtype unique|index + ok A6[ix_tenant_world_type] idxfield exists in fields: tenant_id + ok A6[ix_tenant_world_type] idxfield exists in fields: world_id + ok A6[ix_tenant_world_type] idxfield exists in fields: event_type + DEFECT A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_tx_group] idxfields non-empty array + ok A6[ix_tx_group] idxtype unique|index + ok A6[ix_tx_group] idxfield exists in fields: tx_group + DEFECT A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + ok A6[ix_created_at] idxfields non-empty array + ok A6[ix_created_at] idxtype unique|index + ok A6[ix_created_at] idxfield exists in fields: created_at + DEFECT A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield']) + ok A6 appcodes_kv cond uses parentid= + ok A6 codes.table has no dot notation +== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_entity_state.json + ok A6 root-is-object + ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary']) + ok A6 summary has exactly one record (got 1) + DEFECT A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped']) + ok A6 summary.name non-empty + DEFECT A6 summary.primary is non-empty array + ok A6 fields non-empty + DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + ok A6[tenant_id] abstract type ('str') + ok A6[tenant_id] positive int length + ok A6[tenant_id] nullable in yes|no + DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned']) + ok A6[id] abstract type ('int') + ok A6[id] nullable in yes|no + DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + DEFECT A6[session_id] abstract type ('bigint') + ok A6[session_id] nullable in yes|no + DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + ok A6[entity_id] abstract type ('str') + ok A6[entity_id] positive int length + ok A6[entity_id] nullable in yes|no + DEFECT A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + DEFECT A6[state_json] abstract type ('json') + ok A6[state_json] nullable in yes|no + DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + DEFECT A6[state_version] abstract type ('bigint') + ok A6[state_version] nullable in yes|no + DEFECT A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + ok A6[checksum] abstract type ('str') + ok A6[checksum] positive int length + ok A6[checksum] nullable in yes|no + DEFECT A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + ok A6[updated_by] abstract type ('str') + ok A6[updated_by] positive int length + ok A6[updated_by] nullable in yes|no + DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + ok A6[created_at] abstract type ('datetime') + ok A6[created_at] nullable in yes|no + DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + ok A6[updated_at] abstract type ('datetime') + ok A6[updated_at] nullable in yes|no + DEFECT A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique']) + DEFECT A6[uk_es] idxfields non-empty array + DEFECT A6[uk_es] idxtype unique|index + DEFECT A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique']) + DEFECT A6[PRIMARY] idxfields non-empty array + DEFECT A6[PRIMARY] idxtype unique|index + +judged assertions executed: 54 +upstream audit checks executed: 186 +UPSTREAM DEFECTS (56) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner: + * A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths']) + * A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + * A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type']) + * A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type']) + * A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + * A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + * A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type']) + * A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type']) + * A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type']) + * A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name']) + * A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield']) + * A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped']) + * A6 summary.primary is non-empty array + * A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + * A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned']) + * A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + * A6[session_id] abstract type ('bigint') + * A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + * A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + * A6[state_json] abstract type ('json') + * A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + * A6[state_version] abstract type ('bigint') + * A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + * A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type']) + * A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + * A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type']) + * A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique']) + * A6[uk_es] idxfields non-empty array + * A6[uk_es] idxtype unique|index + * A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique']) + * A6[PRIMARY] idxfields non-empty array + * A6[PRIMARY] idxtype unique|index +RESULT: PASS diff --git a/scripts/sync_models_mirror.py b/scripts/sync_models_mirror.py new file mode 100644 index 0000000..a47892c --- /dev/null +++ b/scripts/sync_models_mirror.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M11b-2b: 把 modules/world_sync/models/ 下的表定义真源同步到应用打包镜像落点。 + +铁律:apps/{app}/pkgs/{module}/models/*.json 是 build 阶段产物,禁止手工双写。 +本脚本是唯一允许的写入通道 —— 真源只有一份(modules/ 侧),镜像由 cp 语义逐字节覆盖生成。 + +用法: + python3 modules/world_sync/scripts/sync_models_mirror.py + python3 modules/world_sync/scripts/sync_models_mirror.py --check # 只校验不写,不一致退出码 1 +""" + +import argparse +import hashlib +import os +import shutil +import sys + +# 表定义真源清单(相对机构工作空间根) +SOURCE_TABLES = [ + "pbl_runtime_event.json", + "pbl_entity_state.json", +] + +MODULE_ROOT = "modules/world_sync" +SOURCE_DIR = os.path.join(MODULE_ROOT, "models") +MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models") + + +def _find_workspace_root(): + """从脚本位置向上回溯,找到同时含 modules/ 与 apps/ 的工作空间根。""" + here = os.path.dirname(os.path.abspath(__file__)) + cur = here + for _ in range(10): + if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")): + return cur + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + # 兜底:脚本位于 modules/{m}/scripts/x.py,则根为上三级 + return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir)) + + +def sha256_of(path): + 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 sync(root, check_only=False): + """同步真源 -> 镜像。返回 (是否全部一致, 明细列表)。""" + src_dir = os.path.join(root, SOURCE_DIR) + dst_dir = os.path.join(root, MIRROR_DIR) + details = [] + all_ok = True + + if not check_only: + os.makedirs(dst_dir, exist_ok=True) + + for name in SOURCE_TABLES: + src = os.path.join(src_dir, name) + dst = os.path.join(dst_dir, name) + if not os.path.isfile(src): + details.append("MISSING-SOURCE: %s" % src) + all_ok = False + continue + + if not check_only: + shutil.copyfile(src, dst) + + src_hash = sha256_of(src) + if not os.path.isfile(dst): + details.append("MISSING-MIRROR: %s" % dst) + all_ok = False + continue + dst_hash = sha256_of(dst) + same = (src_hash == dst_hash) + all_ok = all_ok and same + details.append("%s %s" % ("SAME" if same else "DIFF", name)) + details.append(" sha256 src=%s" % src_hash) + details.append(" sha256 dst=%s" % dst_hash) + + return all_ok, details + + +def main(argv=None): + parser = argparse.ArgumentParser(description="sync world_sync table-definition sources to app mirror") + parser.add_argument("--check", action="store_true", help="only verify, do not write") + args = parser.parse_args(argv) + + root = _find_workspace_root() + ok, details = sync(root, check_only=args.check) + print("workspace root: %s" % root) + print("mode: %s" % ("check" if args.check else "sync")) + for line in details: + print(line) + print("RESULT: %s" % ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/world_sync/pbl_runtime_authority.py b/world_sync/pbl_runtime_authority.py new file mode 100644 index 0000000..e4781d4 --- /dev/null +++ b/world_sync/pbl_runtime_authority.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +"""pbl_runtime_authority —— M11b-2b:``key=schema`` 引用的解析层(列名唯一权威源)。 + +**为什么存在** +PM 裁决(任务 ``u0EO4x3ajntR41acaHj5H``,方案 B):``pbl_runtime_event`` / +``pbl_entity_state`` 两张表的**唯一真源**是 ``modules/pbl_runtime_ext/models/*.json`` +(M11b-1 已批准产物)。world_sync 侧 ``models/*.json`` 只保留 ``key=schema`` 引用, +**不再自带** summary/fields/indexes/codes 结构体。 + +于是「消费方列契约」必须从权威定义**机械导出**,而不是人工抄一遍——人工双写正是 +QC #1/#3 判定的「多份互相矛盾定义」的根因。本模块就是那个导出层: + +* :func:`load_reference` —— 读本模块 ``models/