fix(pbl_compiler): M3b RBAC 29条显式登记+CRUD只读拒绝端点+json根键统一+audit BinOp解析修复

- scripts/load_path.py: PATHS_CONTRACTS 20 条(含 M3b 5 个导出契约) + PATHS_READONLY_DENY 9 条
  = 29 条全显式登记,无通配符;与 wwwroot/api/*.dspy(29) 一一对应
- wwwroot/api/: 新增 9 个 CRUD 只读拒绝端点(create/update/delete × 3 表),
  封死 xls2ui 回退默认写入口,保 rules_hash 29.6 确定性指纹唯一写入路径
- json/*.json: 根级 browserfields 合并进 params,三文件统一 tblname/alias/title/params 根键集,
  editable 齐备 new_/update_/delete_data_url 且指向真实 .dspy
- scripts/audit_rbac_parity.py: 修复 PATHS = A + B (BinOp) 解析取空的自身缺陷(假 FAIL 根因),
  新增 json/ 根键白名单 + editable 三 URL + 幽灵/漏登记 检查
- pbl_compiler/init.py: 文件头注释登记条数与代码事实对齐(29=20+9)
- 实跑: audit_rbac_parity.py rc=0 PASS;test_m3b_mapping.py PASS=113/FAIL=0
This commit is contained in:
agent.develop 2026-09-19 11:04:55 +08:00
parent d203811973
commit cb1132e6a9
15 changed files with 491 additions and 394 deletions

View File

@ -1,131 +1,39 @@
{ {
"tblname": "pbl_capability_registry", "tblname": "pbl_capability_registry",
"alias": "pbl_compiler", "alias": "pbl_capability_registry_list",
"browserfields": { "title": "能力注册表(第11章缺口)",
"id": { "_comment": "M3b 整改(QC #3):删除根级冗余 browserfields(规范只允许 params.browserfields),根键统一为 tblname/alias/title/params;alias 由 pbl_compiler(与模块同名、生成目录会与模块 wwwroot 冲突)改为 pbl_capability_registry_list;params.editable 补齐 new/update/delete_data_url 对象,三写 URL 指向显式拒绝直写的端点(唯一写入口是契约 pbl_capability_register)。",
"label": "ID",
"list": true,
"type": "number"
},
"tenant_id": {
"label": "租户ID",
"list": true,
"type": "text"
},
"capability_key": {
"label": "能力键",
"type": "select",
"list": true
},
"category": {
"label": "分类",
"type": "select",
"list": true
},
"args_schema_json": {
"label": "参数JSON Schema",
"type": "text",
"list": true
},
"permission_required": {
"label": "所需权限",
"type": "select",
"list": true
},
"is_enabled": {
"label": "启用",
"type": "checkbox",
"list": true
},
"version_no": {
"label": "版本",
"type": "number",
"list": true
},
"description": {
"label": "说明",
"type": "text",
"list": true
}
},
"params": { "params": {
"browserfields": { "browserfields": {
"id": { "id": {"label": "ID", "type": "number", "list": true},
"label": "ID", "tenant_id": {"label": "租户ID", "type": "text", "list": true},
"list": true, "capability_key": {"label": "能力键", "type": "text", "list": true},
"type": "number" "category": {"label": "分类", "type": "text", "list": true},
}, "args_schema_json": {"label": "参数JSON Schema", "type": "text", "list": true},
"tenant_id": { "permission_required": {"label": "所需权限", "type": "text", "list": true},
"label": "租户ID", "is_enabled": {"label": "启用", "type": "checkbox", "list": true},
"list": true, "version_no": {"label": "版本", "type": "number", "list": true},
"type": "text" "description": {"label": "说明", "type": "text", "list": true}
},
"capability_key": {
"label": "能力键",
"type": "select",
"list": true
},
"category": {
"label": "分类",
"type": "select",
"list": true
},
"args_schema_json": {
"label": "参数JSON Schema",
"type": "text",
"list": true
},
"permission_required": {
"label": "所需权限",
"type": "select",
"list": true
},
"is_enabled": {
"label": "启用",
"type": "checkbox",
"list": true
},
"version_no": {
"label": "版本",
"type": "number",
"list": true
},
"description": {
"label": "说明",
"type": "text",
"list": true
}
}, },
"editable": { "editable": {
"capability_key": { "get_data_url": {
"label": "能力键", "url": "{{entire_url('/pbl_compiler/api/pbl_capability_list.dspy')}}"
"type": "select"
}, },
"category": { "new_data_url": {
"label": "分类", "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_create.dspy')}}"
"type": "select"
}, },
"args_schema_json": { "update_data_url": {
"label": "参数JSON Schema", "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_update.dspy')}}"
"type": "text"
}, },
"permission_required": { "delete_data_url": {
"label": "所需权限", "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_delete.dspy')}}"
"type": "select"
},
"is_enabled": {
"label": "启用",
"type": "checkbox"
},
"version_no": {
"label": "版本",
"type": "number"
},
"description": {
"label": "说明",
"type": "text"
} }
}, },
"new_data_url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_create.dspy')}}",
"update_data_url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_update.dspy')}}",
"delete_data_url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_delete.dspy')}}",
"editexclouded": ["id", "tenant_id", "created_at", "updated_at"],
"_crud_note": "能力注册表只读列表:注册/更新一律走契约 pbl_capability_register(做 capability_key 规范化、args_schema JSON 校验与租户归属校验),物理删除一律禁止(历史蓝图引用会造成产物不可复现),下线用 is_enabled=0 软标记。三个写端点仅用于满足 editable 格式硬要求并封死 xls2ui 默认写包装,均返回 widgettype=Error。",
"order_by": "id DESC", "order_by": "id DESC",
"page_size": 20 "page_size": 20
} }

View File

@ -1,117 +1,38 @@
{ {
"tblname": "pbl_compiler_version", "tblname": "pbl_compiler_version",
"alias": "pbl_compiler", "alias": "pbl_compiler_version_list",
"browserfields": { "title": "编译器版本(29.6 确定性)",
"id": { "_comment": "M3b 整改(QC #3):删除根级冗余 browserfields(规范只允许 params.browserfields),根键统一为 tblname/alias/title/params;alias 由 pbl_compiler(与模块同名、生成目录会与模块 wwwroot 冲突)改为 pbl_compiler_version_list;params.editable 补齐 new/update/delete_data_url 对象,三写 URL 指向显式拒绝直写的端点(唯一写入口是契约 pbl_compiler_version_register)。",
"label": "ID",
"list": true,
"type": "number"
},
"tenant_id": {
"label": "租户ID",
"list": true,
"type": "text"
},
"code": {
"label": "版本编码",
"type": "select",
"list": true
},
"semver": {
"label": "语义化版本",
"type": "select",
"list": true
},
"rules_hash": {
"label": "规则集SHA256前缀",
"type": "text",
"list": true
},
"entrypoint": {
"label": "入口函数",
"type": "select",
"list": true
},
"enabled": {
"label": "启用",
"type": "checkbox",
"list": true
},
"notes": {
"label": "说明",
"type": "text",
"list": true
}
},
"params": { "params": {
"browserfields": { "browserfields": {
"id": { "id": {"label": "ID", "type": "number", "list": true},
"label": "ID", "tenant_id": {"label": "租户ID", "type": "text", "list": true},
"list": true, "code": {"label": "版本编码", "type": "text", "list": true},
"type": "number" "semver": {"label": "语义化版本", "type": "text", "list": true},
}, "rules_hash": {"label": "规则集SHA256前缀", "type": "text", "list": true},
"tenant_id": { "entrypoint": {"label": "入口函数", "type": "text", "list": true},
"label": "租户ID", "enabled": {"label": "启用", "type": "checkbox", "list": true},
"list": true, "notes": {"label": "说明", "type": "text", "list": true}
"type": "text"
},
"code": {
"label": "版本编码",
"type": "select",
"list": true
},
"semver": {
"label": "语义化版本",
"type": "select",
"list": true
},
"rules_hash": {
"label": "规则集SHA256前缀",
"type": "text",
"list": true
},
"entrypoint": {
"label": "入口函数",
"type": "select",
"list": true
},
"enabled": {
"label": "启用",
"type": "checkbox",
"list": true
},
"notes": {
"label": "说明",
"type": "text",
"list": true
}
}, },
"editable": { "editable": {
"code": { "get_data_url": {
"label": "版本编码", "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_list.dspy')}}"
"type": "select"
}, },
"semver": { "new_data_url": {
"label": "语义化版本", "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_create.dspy')}}"
"type": "select"
}, },
"rules_hash": { "update_data_url": {
"label": "规则集SHA256前缀", "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_update.dspy')}}"
"type": "text"
}, },
"entrypoint": { "delete_data_url": {
"label": "入口函数", "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_delete.dspy')}}"
"type": "select"
},
"enabled": {
"label": "启用",
"type": "checkbox"
},
"notes": {
"label": "说明",
"type": "text"
} }
}, },
"new_data_url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_create.dspy')}}",
"update_data_url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_update.dspy')}}",
"delete_data_url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_delete.dspy')}}",
"editexclouded": ["id", "tenant_id", "rules_hash", "created_at", "updated_at"],
"_crud_note": "编译器版本行一经登记即不可变(版本 diff 与确定性验证以其 rules_hash 为基线):新增/变更一律走契约 pbl_compiler_version_register(计算 rules_hash + semver 唯一性校验),停用走 enabled=0,禁止物理删除(pbl_game_definition.compiler_version_id 引用它)。三个写端点仅用于满足 editable 格式硬要求并封死 xls2ui 默认写包装,均返回 widgettype=Error。",
"order_by": "id DESC", "order_by": "id DESC",
"page_size": 20 "page_size": 20
} }

View File

@ -1,105 +1,67 @@
{ {
"tblname": "pbl_game_definition", "tblname": "pbl_game_definition",
"alias": "pbl_game_definition_list", "alias": "pbl_game_definition_list",
"_comment": "M3b 整改(QC #13):主键/外键类字段在 models/pbl_game_definition.json 中为 str(32),CRUD 渲染类型必须为 text(原 number 会按数字输入框渲染 32 位字符串主键,排序/校验错位);同时补齐 M3b 规则导出四字段 rules_json/rules_hash/rule_count/rules_manifest 与 compile_status/error_message,移除模型中不存在的幽灵字段。", "title": "Game Definition 编译产物",
"_comment": "M3b 整改(QC #13):主键/外键类字段在 models/pbl_game_definition.json 中为 str(32),CRUD 渲染类型必须为 text;补齐 M3b 规则导出四件套 rules_json/rules_hash/rule_count/rules_manifest 与 compile_status/error_message。M3b 整改(QC #2 方案 A):params.editable 由「只有 get_data_url」补成规范要求的四 URL 齐备结构,三个写 URL 指向显式返回 Error 的只读拒绝端点,杜绝 xls2ui 回退生成默认 add_/update_/delete_pbl_game_definition.dspy 绕过 gd_rules_writer 唯一写入口。",
"params": { "params": {
"browserfields": { "browserfields": {
"id": { "id": {"label": "ID", "type": "text", "list": true},
"label": "ID", "tenant_id": {"label": "租户ID", "type": "text", "list": true},
"list": true, "blueprint_id": {"label": "来源蓝图", "type": "text", "list": true},
"type": "text" "blueprint_version_no": {"label": "蓝图版本", "type": "number", "list": true},
}, "compiler_version_id": {"label": "编译版本", "type": "text", "list": true},
"tenant_id": { "content_fingerprint": {"label": "SHA-256 指纹", "type": "text", "list": true},
"label": "租户ID", "rules_hash": {"label": "规则集合指纹", "type": "text", "list": true},
"list": true, "rule_count": {"label": "规则条数", "type": "number", "list": true},
"type": "text" "compile_status": {"label": "编译结果状态", "type": "select", "list": true},
}, "quality_state": {"label": "编译时质量状态", "type": "select", "list": true},
"blueprint_id": { "world_id": {"label": "落库 world", "type": "text", "list": true},
"label": "来源蓝图", "scene_id": {"label": "落库 scene", "type": "text", "list": true},
"type": "text", "duration_ms": {"label": "耗时", "type": "number", "list": true},
"list": true "definition_json": {"label": "Game Definition 正文", "type": "text", "list": false},
}, "rules_json": {"label": "规则映射产物(script_type=1)", "type": "text", "list": false},
"blueprint_version_no": { "rules_manifest": {"label": "规则溯源清单", "type": "text", "list": false},
"label": "蓝图版本", "error_message": {"label": "失败原因", "type": "text", "list": false},
"type": "number", "created_at": {"label": "创建时间", "type": "text", "list": true}
"list": true
},
"compiler_version_id": {
"label": "编译版本",
"type": "text",
"list": true
},
"content_fingerprint": {
"label": "SHA-256 指纹",
"type": "text",
"list": true
},
"rules_hash": {
"label": "规则集合指纹",
"type": "text",
"list": true
},
"rule_count": {
"label": "规则条数",
"type": "number",
"list": true
},
"compile_status": {
"label": "编译结果状态",
"type": "select",
"list": true
},
"quality_state": {
"label": "编译时质量状态",
"type": "select",
"list": true
},
"world_id": {
"label": "落库 world",
"type": "text",
"list": true
},
"scene_id": {
"label": "落库 scene",
"type": "text",
"list": true
},
"duration_ms": {
"label": "耗时",
"type": "number",
"list": true
},
"definition_json": {
"label": "Game Definition 正文",
"type": "text",
"list": false
},
"rules_json": {
"label": "规则映射产物(script_type=1)",
"type": "text",
"list": false
},
"rules_manifest": {
"label": "规则溯源清单",
"type": "text",
"list": false
},
"error_message": {
"label": "失败原因",
"type": "text",
"list": false
},
"created_at": {
"label": "创建时间",
"type": "text",
"list": true
}
}, },
"editable": { "editable": {
"get_data_url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_get.dspy')}}" "get_data_url": {
"url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_get.dspy')}}"
},
"new_data_url": {
"url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_create.dspy')}}"
},
"update_data_url": {
"url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_update.dspy')}}"
},
"delete_data_url": {
"url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_delete.dspy')}}"
}
}, },
"_crud_note": "pbl_game_definition 是编译产物表(只读):正文与规则四件套由 pbl_compiler_compile 主流程经 gd_artifact_writer/gd_rules_writer 唯一写入口落库,刻意不提供 new_data_url/update_data_url/delete_data_url,避免手工增删改绕过 29.6 确定性指纹与 rules_hash 收口。QC #3 整改:params.editable 原被挪用为编辑字段 label/type 映射(非法结构),现改为规范要求的只读 editable 对象;同时删除根级冗余 browserfields(规范只允许 params.browserfields),alias 由 pbl_compiler(与模块同名、会与模块 wwwroot 冲突)改为 pbl_game_definition_list。", "new_data_url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_create.dspy')}}",
"update_data_url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_update.dspy')}}",
"delete_data_url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_delete.dspy')}}",
"editexclouded": [
"id",
"tenant_id",
"content_fingerprint",
"definition_json",
"rules_json",
"rules_hash",
"rule_count",
"rules_manifest",
"compile_status",
"quality_state",
"duration_ms",
"error_message",
"created_by",
"created_at",
"updated_by",
"updated_at",
"is_deleted"
],
"_crud_note": "只读产物表:pbl_game_definition 的正文(definition_json)与规则四件套(rules_json/rules_hash/rule_count/rules_manifest)只能由 pbl_compiler_compile 主流程经 gd_artifact_writer / gd_rules_writer 唯一写入口落库(29.6 确定性指纹 + rules_hash 收口)。为满足 crud-definition-spec「editable 必须齐备 new/update/delete_data_url」的格式硬要求,三个写 URL 指向 wwwroot/api/pbl_game_definition_{create,update,delete}.dspy —— 这三个端点一律返回 widgettype=Error(『编译产物只读,禁止手工增删改』),既不产生可写路径,也不允许 xls2ui 生成默认写包装。URL 同时写在 params 顶层(xls2ddl 从顶层读取,嵌套会被忽略并回退默认 add_ 端点)。",
"order_by": "id DESC", "order_by": "id DESC",
"page_size": 20 "page_size": 20
} }
} }

View File

@ -10,7 +10,7 @@ QC #1 整改(M3b 本轮):CONTRACTS 由 15 → 20,新增 rules_export_api
「第12章规则映射 / 导出 / game_definition 写入」契约,并导出 GD 规则写入器与纯映射 「第12章规则映射 / 导出 / game_definition 写入」契约,并导出 GD 规则写入器与纯映射
层函数(宿主应用与自测脚本可直接复用)。本文件的 CONTRACTS 是三处同步的唯一事实源: 层函数(宿主应用与自测脚本可直接复用)。本文件的 CONTRACTS 是三处同步的唯一事实源:
· wwwroot/api/<契约名>.dspy 薄包装(一一对应,共 20 个) · wwwroot/api/<契约名>.dspy 薄包装(一一对应,共 20 个)
· scripts/load_path.py PATHS(RBAC 逐条显式登记,共 20 条 + 只读列表 CRUD 6 条) · scripts/load_path.py PATHS(RBAC 逐条显式登记,共 29 条 = Web 契约端点 20 + CRUD 只读拒绝端点 9,无通配符)
· __init__.py import + __all__ · __init__.py import + __all__
""" """
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv

View File

@ -1,25 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_compiler 接线/RBAC 一致性机械审计(QC 硬门禁自证工具)。 """pbl_compiler 接线 / RBAC / CRUD 定义 一致性机械审计(QC 硬门禁自证工具)。
背景:连续多轮 QC 退回的根因都是「写了但没接线 / 接了线但没登记 RBAC」—— 背景:连续多轮 QC 退回的根因都是「写了但没接线 / 接了线但没登记 RBAC /
人肉声明不可信,故本脚本用机械比对把四张登记面钉死一致: CRUD 定义格式不合规」——人肉声明不可信,故本脚本用机械比对钉死所有登记面。
① pbl_compiler/init.py CONTRACTS 契约名集合 比对集合:
② pbl_compiler/__init__.py __all__ 导出集合(须 ⊇ CONTRACTS) 期望端点集合 EXPECTED = CONTRACTS ∪ READONLY_DENY
③ wwwroot/api/*.dspy 实际端点文件集合(须 == CONTRACTS) ① pbl_compiler/init.py CONTRACTS 契约名集合(20)
④ scripts/load_path.py PATHS 登记集合(须 == CONTRACTS) ② pbl_compiler/__init__.py __all__ 导出集合(须 ⊇ CONTRACTS)
③ wwwroot/api/*.dspy 实际端点文件集合(须 == EXPECTED)
并额外检查: ④ scripts/load_path.py PATHS 登记集合(须 == EXPECTED)
· load_path.py 无通配符(规范硬规定:禁 % / *) ⑤ scripts/load_path.py PATHS_READONLY_DENY(CRUD 只读拒绝端点,非契约)
· 每个 .dspy 显式 return、除 sqlor.filter 外无顶层 import、无 ServerEnv() 取请求态 另加 json/*.json 结构审计(QC #3:改任一 CRUD 文件必须扫 json/ 全部,Pitfall 10):
· 实现层:契约名必须能在 api.py / rules_export_api.py 中找到同名 async def(防幽灵注册) · 根键白名单 {tblname, alias, title, params, _comment}(禁根级 browserfields)
· wwwroot/ 下若出现 CRUD 生成子目录,其 index.ui + get_/add_/update_/delete_*.dspy · browserfields 只允许挂在 params 下
必须在 PATHS 中逐条登记(防「生成目录未登记 → 403」复发) · alias 不得与模块名同名(会与模块 wwwroot 目录冲突)
· params.editable 必须齐备 new_data_url / update_data_url / delete_data_url
且三个 URL 指向的 .dspy 文件真实存在(防 xls2ui 回退默认写入口)
· browserfields/editexclouded 引用的字段必须存在于 models/{tblname}.json
退出码:0 全通过;1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。 退出码:0 全通过;1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。
""" """
import ast import ast
import json
import os import os
import re import re
import sys import sys
@ -29,6 +33,8 @@ ROOT = os.path.dirname(HERE) # 模块仓库根
PKG = os.path.join(ROOT, 'pbl_compiler') PKG = os.path.join(ROOT, 'pbl_compiler')
WWW = os.path.join(ROOT, 'wwwroot') WWW = os.path.join(ROOT, 'wwwroot')
API = os.path.join(WWW, 'api') API = os.path.join(WWW, 'api')
JSONDIR = os.path.join(ROOT, 'json')
MODELSDIR = os.path.join(ROOT, 'models')
failures = [] failures = []
notes = [] notes = []
@ -75,6 +81,14 @@ def dict_keys(node):
return set() return set()
def literal_or_none(node):
"""AST 节点 → python 值;失败返回 None。"""
try:
return ast.literal_eval(node) if isinstance(node, ast.AST) else node
except Exception: # noqa: BLE001
return None
# ── ① init.py CONTRACTS ──────────────────────────────────────────────────── # ── ① init.py CONTRACTS ────────────────────────────────────────────────────
init_py = os.path.join(PKG, 'init.py') init_py = os.path.join(PKG, 'init.py')
CONTRACTS = set() CONTRACTS = set()
@ -116,51 +130,101 @@ else:
else: else:
ok('__init__.py __all__ 覆盖全部 %d 个契约' % len(CONTRACTS)) ok('__init__.py __all__ 覆盖全部 %d 个契约' % len(CONTRACTS))
# ── ③ wwwroot/api/*.dspy ────────────────────────────────────────────────── # ── ⑤ load_path.py:PATHS / PATHS_CONTRACTS / PATHS_READONLY_DENY ──────────
dspy_files = sorted(f for f in os.listdir(API) if f.endswith('.dspy')) \
if os.path.isdir(API) else []
DSPY_NAMES = {f[:-len('.dspy')] for f in dspy_files}
if DSPY_NAMES != CONTRACTS:
fail('wwwroot/api/ 端点与 CONTRACTS 不一致:缺端点=%s 多余端点=%s'
% (sorted(CONTRACTS - DSPY_NAMES), sorted(DSPY_NAMES - CONTRACTS)))
else:
ok('wwwroot/api/*.dspy = %d 个,与 CONTRACTS 一一对应' % len(DSPY_NAMES))
# ── ④ scripts/load_path.py PATHS ──────────────────────────────────────────
lp_py = os.path.join(HERE, 'load_path.py') lp_py = os.path.join(HERE, 'load_path.py')
PATH_NAMES = set() entries, contract_entries, deny_entries = [], [], []
entries = [] READONLY_DENY = set()
if not os.path.isfile(lp_py): if not os.path.isfile(lp_py):
fail('缺 scripts/load_path.py') fail('缺 scripts/load_path.py')
else: else:
_, consts, _, _ = parse(lp_py) _, consts, _, _ = parse(lp_py)
raw = consts.get('PATHS') # 先把各列表常量解析成 python 值,再解析 PATHS = A + B(Name 需回查 consts)。
if isinstance(raw, ast.List): # 关键:parse() 对「纯字面量列表」已 literal_eval 成 python list,只有求值失败
raw = [ast.literal_eval(e) for e in raw.elts] # 的赋值才保留 AST。两种形态都必须收进 resolved —— 否则 PATHS = PATHS_CONTRACTS
if not raw: # + PATHS_READONLY_DENY 的 BinOp 回查会拿到空列表,把已登记的 29 条误判成
fail('load_path.py PATHS 为空(RBAC 零登记 → 全部 403)') # 「PATHS 为空(RBAC 零登记)」,产生假 FAIL(本轮排查出的审计工具自身缺陷)。
for item in raw or []: resolved = {}
if not (isinstance(item, (list, tuple)) and len(item) == 2): for var, raw in consts.items():
fail('load_path.py PATHS 元素必须是 (path, role) 二元组: %r' % (item,)) if isinstance(raw, list): # parse() 已求值的纯字面量列表
resolved[var] = raw
elif isinstance(raw, ast.List): # 含函数引用等求值失败的列表 AST
try:
resolved[var] = [ast.literal_eval(e) for e in raw.elts]
except Exception: # noqa: BLE001
resolved[var] = []
for var, bucket in (('PATHS', entries),
('PATHS_CONTRACTS', contract_entries),
('PATHS_READONLY_DENY', deny_entries)):
raw = consts.get(var)
if raw is None:
fail('load_path.py 缺 %s 列表' % var)
continue continue
entries.append((item[0], item[1])) if isinstance(raw, ast.List):
PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')] raw = resolved.get(var, [])
for p, _ in entries if '/api/' in p} elif isinstance(raw, ast.Name):
if PATH_NAMES != CONTRACTS: raw = resolved.get(raw.id, [])
fail('load_path.py PATHS 与 CONTRACTS 不一致:未登记=%s 幽灵登记=%s' elif isinstance(raw, ast.BinOp): # PATHS = PATHS_CONTRACTS + PATHS_READONLY_DENY
% (sorted(CONTRACTS - PATH_NAMES), sorted(PATH_NAMES - CONTRACTS))) left = resolved.get(getattr(raw.left, 'id', None), []) if isinstance(raw.left, ast.Name) \
else: else (literal_or_none(raw.left) or [])
ok('load_path.py PATHS 登记 %d 条,与 CONTRACTS 一一对应' % len(PATH_NAMES)) right = resolved.get(getattr(raw.right, 'id', None), []) if isinstance(raw.right, ast.Name) \
else (literal_or_none(raw.right) or [])
raw = list(left) + list(right)
for item in raw or []:
if not (isinstance(item, (list, tuple)) and len(item) == 2):
fail('load_path.py %s 元素必须是 (path, role) 二元组: %r' % (var, item))
continue
bucket.append((item[0], item[1]))
if not entries:
fail('load_path.py PATHS 为空(RBAC 零登记 → 全部 403)')
READONLY_DENY = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in deny_entries if '/api/' in p}
for p, _role in entries: for p, _role in entries:
if '%' in p or '*' in p: if '%' in p or '*' in p:
fail('load_path.py 禁用通配符: %s' % p) fail('load_path.py 禁用通配符: %s' % p)
if not any('%' in p or '*' in p for p, _ in entries): if not any('%' in p or '*' in p for p, _ in entries):
ok('load_path.py 无通配符(%d 条全显式)' % len(entries)) ok('load_path.py 无通配符(%d 条全显式)' % len(entries))
n_api = len([1 for p, _ in entries if '/api/' in p]) dup = len(entries) - len({p for p, _ in entries})
if dup:
fail('load_path.py 存在重复登记 %d 条' % dup)
else:
ok('load_path.py 登记 %d 条无重复(契约 %d + 只读拒绝 %d)'
% (len(entries), len(contract_entries), len(deny_entries)))
PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in entries if '/api/' in p}
CONTRACT_PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in contract_entries if '/api/' in p}
EXPECTED = CONTRACTS | READONLY_DENY
if CONTRACT_PATH_NAMES != CONTRACTS:
fail('PATHS_CONTRACTS 与 CONTRACTS 不一致:未登记=%s 幽灵登记=%s'
% (sorted(CONTRACTS - CONTRACT_PATH_NAMES),
sorted(CONTRACT_PATH_NAMES - CONTRACTS)))
else:
ok('PATHS_CONTRACTS 与 CONTRACTS 一一对应(%d 条)' % len(CONTRACTS))
if PATH_NAMES != EXPECTED:
fail('PATHS 与 CONTRACTS∪READONLY_DENY 不一致:未登记=%s 幽灵登记=%s'
% (sorted(EXPECTED - PATH_NAMES), sorted(PATH_NAMES - EXPECTED)))
else:
ok('PATHS 登记 %d 条,与期望端点集合(契约 %d + 只读拒绝 %d)一一对应'
% (len(PATH_NAMES), len(CONTRACTS), len(READONLY_DENY)))
# ── ③ wwwroot/api/*.dspy ──────────────────────────────────────────────────
dspy_files = sorted(f for f in os.listdir(API) if f.endswith('.dspy')) \
if os.path.isdir(API) else []
DSPY_NAMES = {f[:-len('.dspy')] for f in dspy_files}
if DSPY_NAMES != EXPECTED:
fail('wwwroot/api/ 端点与期望集合不一致:缺端点=%s 多余端点=%s'
% (sorted(EXPECTED - DSPY_NAMES), sorted(DSPY_NAMES - EXPECTED)))
else:
ok('wwwroot/api/*.dspy = %d 个,与 CONTRACTS∪READONLY_DENY 一一对应'
% len(DSPY_NAMES))
n_api = len([1 for p, _ in entries if '/api/' in p])
if n_api != len(dspy_files):
fail('端点数与 RBAC 登记数不等:%d != %d' % (n_api, len(dspy_files)))
else:
ok('grep 口径自证:PATHS 中 /api/ 条目=%d,wwwroot/api/*.dspy=%d' ok('grep 口径自证:PATHS 中 /api/ 条目=%d,wwwroot/api/*.dspy=%d'
% (n_api, len(dspy_files))) % (n_api, len(dspy_files)))
if n_api != len(dspy_files):
fail('端点数与 RBAC 登记数不等:%d != %d' % (n_api, len(dspy_files)))
# ── .dspy 规范审计(dspy-file-implementation-spec)───────────────────────── # ── .dspy 规范审计(dspy-file-implementation-spec)─────────────────────────
bad_import, no_return, env_req = [], [], [] bad_import, no_return, env_req = [], [], []
@ -188,6 +252,22 @@ if env_req:
else: else:
ok('.dspy 无 ServerEnv() 取请求态') ok('.dspy 无 ServerEnv() 取请求态')
# ── 只读拒绝端点必须真返回 Error(防「名义拒绝、实际可写」)─────────────────
deny_hollow = []
for name in sorted(READONLY_DENY):
p = os.path.join(API, name + '.dspy')
if not os.path.isfile(p):
deny_hollow.append('%s: 文件缺失' % name)
continue
text = open(p, 'r', encoding='utf-8').read()
if 'widgettype' not in text or '"Error"' not in text and "'Error'" not in text:
deny_hollow.append('%s: 未返回 Error widget' % name)
if deny_hollow:
fail('只读拒绝端点空心化(未显式拒绝写入): %s' % deny_hollow)
else:
ok('%d 个只读拒绝端点均显式返回 Error widget(CRUD 写入口已封死)'
% len(READONLY_DENY))
# ── CRUD 生成目录登记面(防「生成目录未登记 → 403」)─────────────────────── # ── CRUD 生成目录登记面(防「生成目录未登记 → 403」)───────────────────────
SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'} SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'}
crud_dirs = [d for d in sorted(os.listdir(WWW)) crud_dirs = [d for d in sorted(os.listdir(WWW))
@ -204,11 +284,93 @@ for alias in crud_dirs:
if crud_dirs: if crud_dirs:
ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs)) ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs))
else: else:
ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy),' ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy)')
'无需登记 CRUD 6 条路径')
# ── json/*.json 结构审计(QC #3 + Pitfall 10:改一个必须全扫)──────────────
ROOT_ALLOWED = {'tblname', 'alias', 'title', 'params', '_comment'}
json_files = sorted(f for f in os.listdir(JSONDIR) if f.endswith('.json')) \
if os.path.isdir(JSONDIR) else []
if not json_files:
fail('json/ 目录为空(CRUD 定义缺失)')
root_key_bad, alias_bad, editable_bad, url_missing, field_ghost = [], [], [], [], []
for fn in json_files:
path = os.path.join(JSONDIR, fn)
try:
d = json.load(open(path, 'r', encoding='utf-8'))
except Exception as exc: # noqa: BLE001
fail('json/%s 不是合法 JSON: %s' % (fn, exc))
continue
rk = set(d.keys())
if not rk <= ROOT_ALLOWED:
root_key_bad.append('%s: 非法根键 %s' % (fn, sorted(rk - ROOT_ALLOWED)))
continue
if 'browserfields' in rk:
root_key_bad.append('%s: 根级 browserfields(规范只允许 params.browserfields)' % fn)
continue
alias = d.get('alias')
if alias == 'pbl_compiler':
alias_bad.append('%s: alias 与模块同名,生成目录会与模块 wwwroot 冲突' % fn)
params = d.get('params') or {}
if 'browserfields' not in params:
root_key_bad.append('%s: 缺 params.browserfields' % fn)
editable = params.get('editable')
if not isinstance(editable, dict):
editable_bad.append('%s: params.editable 不是对象(%r)' % (fn, type(editable).__name__))
continue
need = ('new_data_url', 'update_data_url', 'delete_data_url')
absent = [k for k in need if k not in editable]
if absent:
editable_bad.append('%s: editable 缺 %s' % (fn, absent))
for k in need:
v = editable.get(k)
if v is None:
continue
if not isinstance(v, dict) or 'url' not in v:
editable_bad.append('%s: editable.%s 必须是含 url 的对象' % (fn, k))
continue
m = re.search(r"entire_url\('([^']+)'\)", v['url'])
if not m:
editable_bad.append('%s: editable.%s.url 未用 entire_url()' % (fn, k))
continue
ref = m.group(1)
base = os.path.basename(ref)
if base.startswith('/'):
base = base.lstrip('/')
if not (os.path.isfile(os.path.join(API, base))
or os.path.isfile(os.path.join(ROOT, 'wwwroot', base))):
url_missing.append('%s: editable.%s → %s 无匹配 .dspy' % (fn, k, ref))
gd = editable.get('get_data_url')
if isinstance(gd, dict) and gd.get('url'):
m = re.search(r"entire_url\('([^']+)'\)", gd['url'])
if m and not os.path.isfile(os.path.join(API, os.path.basename(m.group(1)))):
url_missing.append('%s: get_data_url → %s 无匹配 .dspy' % (fn, m.group(1)))
# 字段幽灵检查:browserfields 引用的字段必须在 models/{tblname}.json 存在
mp = os.path.join(MODELSDIR, (d.get('tblname') or '') + '.json')
if os.path.isfile(mp):
cols = {f.get('name') for f in json.load(open(mp, 'r', encoding='utf-8')).get('fields', [])}
for scope in ('browserfields', 'editexclouded'):
bf = params.get(scope)
names = list(bf.keys()) if isinstance(bf, dict) and scope == 'browserfields' \
else (bf or [])
for name in names:
if name not in cols:
field_ghost.append('%s: %s 引用 models 中不存在的字段 %s'
% (fn, scope, name))
else:
field_ghost.append('%s: 缺 models/%s.json' % (fn, d.get('tblname')))
for lst, msg in ((root_key_bad, 'json/ 根键不合规'),
(alias_bad, 'json/ alias 不合规'),
(editable_bad, 'json/ editable 段不合规'),
(url_missing, 'json/ editable URL 无匹配 .dspy'),
(field_ghost, 'json/ 字段与 models 不一致')):
if lst:
fail('%s: %s' % (msg, lst))
if not (root_key_bad or alias_bad or editable_bad or url_missing or field_ghost):
ok('json/*.json 共 %d 个:根键白名单(%s) / alias / editable 三 URL 齐备且指向真实 .dspy '
'/ 字段与 models 全对齐' % (len(json_files), sorted(ROOT_ALLOWED)))
# ── 输出 ─────────────────────────────────────────────────────────────────── # ── 输出 ───────────────────────────────────────────────────────────────────
print('=== pbl_compiler 接线/RBAC parity 审计 ===') print('=== pbl_compiler 接线/RBAC/CRUD parity 审计 ===')
for line in notes: for line in notes:
print(' ' + line) print(' ' + line)
if failures: if failures:
@ -217,6 +379,7 @@ if failures:
print(' ' + line) print(' ' + line)
print('RESULT: FAIL') print('RESULT: FAIL')
sys.exit(1) sys.exit(1)
print('RESULT: PASS (contracts=%d, dspy=%d, rbac_api_paths=%d)' print('RESULT: PASS (contracts=%d, readonly_deny=%d, dspy=%d, rbac_api_paths=%d, json=%d)'
% (len(CONTRACTS), len(dspy_files), len(PATH_NAMES))) % (len(CONTRACTS), len(READONLY_DENY), len(dspy_files), len(PATH_NAMES),
len(json_files)))
sys.exit(0) sys.exit(0)

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC #1、#11)。 """pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC 第四章 RBAC)。
约定: 约定:
- 路径 = 模块自动路由 `/pbl_compiler/api/<契约>.dspy`,不带端口、不带 /wss 前缀; - 路径 = 模块自动路由 `/pbl_compiler/api/<契约>.dspy`,不带端口、不带 /wss 前缀;
@ -8,30 +8,32 @@
- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过); - 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过);
- **禁止通配符**(规范硬规定),每条 .dspy 显式登记。 - **禁止通配符**(规范硬规定),每条 .dspy 显式登记。
登记面与事实源(四处必须一一对应,数量一致): 两个登记集合(合计 29 条,全部显式):
① 实现:pbl_compiler/api.py(15 个 M3a 契约)+ pbl_compiler/rules_export_api.py(5 个 M3b 契约) PATHS_CONTRACTS —— 20 条:Web 契约端点,与四处事实源一一对应
② 导出:pbl_compiler/__init__.py import + __all__ ① 实现:pbl_compiler/api.py(15 个 M3a 契约)+ pbl_compiler/rules_export_api.py(5 个 M3b 契约)
③ 注册:pbl_compiler/init.py CONTRACTS(共 20 条) ② 导出:pbl_compiler/__init__.py import + __all__
④ 端点:wwwroot/api/<契约名>.dspy(共 20 个)→ 本文件 PATHS(共 20 条) ③ 注册:pbl_compiler/init.py CONTRACTS(共 20 条)
④ 端点:wwwroot/api/<契约名>.dspy(共 20 个)
PATHS_READONLY_DENY —— 9 条:CRUD 只读拒绝端点(QC #2 方案 A)。
json/*.json 三张表(pbl_game_definition / pbl_capability_registry /
pbl_compiler_version)都是编译产物或需经专用契约写入的注册表,
按 crud-definition-spec 要求 editable 必须齐备 new_/update_/delete_data_url,
否则 xls2ui 回退生成默认 add_/update_/delete_<table>.dspy —— 等于绕过
gd_rules_writer / pbl_capability_register / pbl_compiler_version_register
唯一写入口,rules_hash(29.6 确定性指纹)可被手工改写。
故为 3 张表 × 3 个写操作各建一个显式返回 Error 的 .dspy(共 9 个),
它们不是 ServerEnv 契约(不进 CONTRACTS/__init__.py),只需 RBAC 登记。
QC #1 整改(本轮,第四章 RBAC 硬门禁): 关于 CRUD 生成目录(/pbl_compiler/<alias> 及 index.ui/get_/add_/update_/delete_ 五条):
上一轮新增 5 个 M3b Web 契约(pbl_compiler_export_rules / pbl_compiler_rules_preview / 本轮 json/ 三个定义均为只读列表(无新增/编辑表单入口),wwwroot/ 下当前无 CRUD 生成子目录。
pbl_compiler_rules_diff / pbl_game_definition_rules_get / pbl_script_rule_list)时, build.sh 重跑 xls2ui 后若生成 wwwroot/<alias>/ 目录,其 index.ui 与 get_<alias>.dspy
wwwroot/api/ 已有 20 个 .dspy,但本文件 PATHS 仍只有 15 条 → 新接口未入 permission 表, 必须按 scripts/audit_rbac_parity.py 的 CRUD 登记面检查逐条补登(禁通配);
登录后调用一律 403,M3b 规则映射/导出能力实际不可用。现逐条显式补齐至 20 条。 在此之前不登记不存在的路径——幽灵登记会污染 permission 表并被审计判 FAIL。
关于 CRUD 生成目录(/pbl_compiler/pbl_script_rule 及 index.ui/get_/add_/update_/delete_ 五条): 自证(交付前实跑 scripts/audit_rbac_parity.py):
本模块 **未** 引入任何 pbl_script_rule 的 CRUD 定义(json/ 下仅 ls wwwroot/api/*.dspy | wc -l → 29
compiler_pbl_capability_registry / compiler_pbl_compiler_version / PATHS 中 /api/ 条目数 → 29(契约 20 + 只读拒绝 9)
compiler_pbl_game_definition 三个只读列表定义,且 wwwroot/ 下无 CRUD 生成子目录, 集合差为空(无漏登记、无幽灵登记)
仅 index.ui + api/)。pbl_script_rule 的只读列表能力由手写契约
`api/pbl_script_rule_list.dspy` 提供,故此处不登记不存在的 CRUD 路径——
登记不存在的路径会污染 permission 表,且 scripts/audit_rbac_parity.py 会判为 FAIL。
自证(交付前实跑,见 scripts/audit_rbac_parity.py):
grep -c "api/" scripts/load_path.py → 20
ls wwwroot/api/*.dspy | wc -l → 20
两者集合差为空(无漏登记、无幽灵登记)
""" """
import os import os
import subprocess import subprocess
@ -39,8 +41,8 @@ import sys
MODULE = 'pbl_compiler' MODULE = 'pbl_compiler'
# (path, role) —— 与 init.py CONTRACTS 一一对应(20 个端点,无通配符) # ── ① Web 契约端点(20 条,与 init.py CONTRACTS 一一对应,无通配符)──────────
PATHS = [ PATHS_CONTRACTS = [
# —— M3a:编译主流程 / 预览 / 对比(3)—— # —— M3a:编译主流程 / 预览 / 对比(3)——
('/pbl_compiler/api/pbl_compiler_compile.dspy', 'logined'), ('/pbl_compiler/api/pbl_compiler_compile.dspy', 'logined'),
('/pbl_compiler/api/pbl_compiler_preview.dspy', 'logined'), ('/pbl_compiler/api/pbl_compiler_preview.dspy', 'logined'),
@ -62,7 +64,7 @@ PATHS = [
# —— M3a:能力注册表(2)—— # —— M3a:能力注册表(2)——
('/pbl_compiler/api/pbl_capability_list.dspy', 'logined'), ('/pbl_compiler/api/pbl_capability_list.dspy', 'logined'),
('/pbl_compiler/api/pbl_capability_register.dspy', 'logined'), ('/pbl_compiler/api/pbl_capability_register.dspy', 'logined'),
# —— M3b:第12章 event→condition→response 规则映射与导出(5,QC #1 本轮补齐)—— # —— M3b:第12章 event→condition→response 规则映射与导出(5)——
('/pbl_compiler/api/pbl_compiler_export_rules.dspy', 'logined'), ('/pbl_compiler/api/pbl_compiler_export_rules.dspy', 'logined'),
('/pbl_compiler/api/pbl_compiler_rules_preview.dspy', 'logined'), ('/pbl_compiler/api/pbl_compiler_rules_preview.dspy', 'logined'),
('/pbl_compiler/api/pbl_compiler_rules_diff.dspy', 'logined'), ('/pbl_compiler/api/pbl_compiler_rules_diff.dspy', 'logined'),
@ -70,12 +72,42 @@ PATHS = [
('/pbl_compiler/api/pbl_script_rule_list.dspy', 'logined'), ('/pbl_compiler/api/pbl_script_rule_list.dspy', 'logined'),
] ]
# ── ② CRUD 只读拒绝端点(9 条,非契约,仅封死 xls2ui 默认写入口,QC #2 方案 A)──
PATHS_READONLY_DENY = [
# pbl_game_definition:编译产物表,唯一写入口 gd_rules_writer.write_gd_rules()
('/pbl_compiler/api/pbl_game_definition_create.dspy', 'logined'),
('/pbl_compiler/api/pbl_game_definition_update.dspy', 'logined'),
('/pbl_compiler/api/pbl_game_definition_delete.dspy', 'logined'),
# pbl_capability_registry:唯一写入口契约 pbl_capability_register
('/pbl_compiler/api/pbl_capability_registry_create.dspy', 'logined'),
('/pbl_compiler/api/pbl_capability_registry_update.dspy', 'logined'),
('/pbl_compiler/api/pbl_capability_registry_delete.dspy', 'logined'),
# pbl_compiler_version:唯一写入口契约 pbl_compiler_version_register
('/pbl_compiler/api/pbl_compiler_version_create.dspy', 'logined'),
('/pbl_compiler/api/pbl_compiler_version_update.dspy', 'logined'),
('/pbl_compiler/api/pbl_compiler_version_delete.dspy', 'logined'),
]
# 完整登记面(audit_rbac_parity.py 以本变量为事实源)
PATHS = PATHS_CONTRACTS + PATHS_READONLY_DENY
def registered_api_paths(): def registered_api_paths():
"""返回 PATHS 中登记的 /api/ 端点文件名集合(供 parity 审计复用)。""" """返回 PATHS 中登记的 /api/ 端点文件名集合(供 parity 审计复用)。"""
return {p.rsplit('/', 1)[-1] for p, _role in PATHS if '/api/' in p} return {p.rsplit('/', 1)[-1] for p, _role in PATHS if '/api/' in p}
def registered_contract_paths():
"""返回 Web 契约端点文件名集合(与 init.py CONTRACTS 比对的口径)。"""
return {p.rsplit('/', 1)[-1] for p, _role in PATHS_CONTRACTS if '/api/' in p}
def registered_readonly_deny_paths():
"""返回只读拒绝端点文件名集合(非契约,QC #2 方案 A)。"""
return {p.rsplit('/', 1)[-1] for p, _role in PATHS_READONLY_DENY
if '/api/' in p}
def register(): def register():
"""逐条注册 RBAC 路径。rbac CLI 不在位时收集为 pending 并打印(不静默跳过)。 """逐条注册 RBAC 路径。rbac CLI 不在位时收集为 pending 并打印(不静默跳过)。
@ -96,8 +128,9 @@ def register():
else: else:
missing.append((path, role)) missing.append((path, role))
# 4 个占位符 ↔ 4 个实参(QC #3 修复保持) # 4 个占位符 ↔ 4 个实参(QC #3 修复保持)
print('[%s] rbac paths: total=%d ok=%d pending=%d' print('[%s] rbac paths: total=%d (contracts=%d, readonly_deny=%d) ok=%d pending=%d'
% (MODULE, len(PATHS), done, len(missing))) % (MODULE, len(PATHS), len(PATHS_CONTRACTS),
len(PATHS_READONLY_DENY), done, len(missing)))
for path, role in missing: for path, role in missing:
print(' PENDING %-12s %s' % (role, path)) print(' PENDING %-12s %s' % (role, path))
return len(missing) == 0 return len(missing) == 0

View File

@ -0,0 +1,13 @@
# pbl_capability_registry_create.dspy —— 显式拒绝 CRUD 直写(M3b QC #2/#3 格式统一)。
# pbl_capability_registry 的唯一写入契约是 pbl_capability_register.dspy
# (内部做 capability_key 规范化、args_schema JSON 校验与 org/tenant 归属校验)。
# 本端点只为满足 CRUD editable 三 URL 齐备的格式要求,并封死绕过校验的默认 add_ 路径。
debug('pbl_capability_registry_create.dspy: REJECTED direct write params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "请使用契约 pbl_capability_register 注册能力,禁止 CRUD 直写",
"data": {
"widgettype": "Error",
"options": {"label": "请使用契约 pbl_capability_register 注册能力,禁止 CRUD 直写"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_capability_registry_delete.dspy —— 显式拒绝 CRUD 直删(M3b QC #2/#3 格式统一)。
# 已注册能力可能被历史蓝图/已编译 Game Definition 引用,物理删除会造成产物不可复现;
# 下线请用 pbl_capability_register 置 is_enabled=0(软下线,保留引用完整性)。
debug('pbl_capability_registry_delete.dspy: REJECTED direct delete params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "能力注册表禁止物理删除,请置 is_enabled=0 软下线",
"data": {
"widgettype": "Error",
"options": {"label": "能力注册表禁止物理删除,请置 is_enabled=0 软下线"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_capability_registry_update.dspy —— 显式拒绝 CRUD 直写(M3b QC #2/#3 格式统一)。
# 能力定义变更必须经契约 pbl_capability_register(upsert 语义 + schema 校验 + 审计),
# 手工 update 会让编译器读到未校验的 args_schema,破坏确定性编译。
debug('pbl_capability_registry_update.dspy: REJECTED direct write params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "请使用契约 pbl_capability_register 更新能力,禁止 CRUD 直写",
"data": {
"widgettype": "Error",
"options": {"label": "请使用契约 pbl_capability_register 更新能力,禁止 CRUD 直写"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_compiler_version_create.dspy —— 显式拒绝 CRUD 直写(M3b QC #2/#3 格式统一)。
# 编译器版本行的唯一写入契约是 pbl_compiler_version_register.dspy
# (内部计算 rules_hash 并做 semver 唯一性校验)。手工新增会产生无指纹的版本行。
debug('pbl_compiler_version_create.dspy: REJECTED direct write params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "请使用契约 pbl_compiler_version_register 登记版本,禁止 CRUD 直写",
"data": {
"widgettype": "Error",
"options": {"label": "请使用契约 pbl_compiler_version_register 登记版本,禁止 CRUD 直写"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_compiler_version_delete.dspy —— 显式拒绝 CRUD 直删(M3b QC #2/#3 格式统一)。
# 版本行是编译产物溯源链的一环(pbl_game_definition.compiler_version_id 引用它),
# 物理删除会产生悬空引用;停用请用契约 pbl_compiler_version_register 置 enabled=0。
debug('pbl_compiler_version_delete.dspy: REJECTED direct delete params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "编译器版本行禁止物理删除,请置 enabled=0 停用",
"data": {
"widgettype": "Error",
"options": {"label": "编译器版本行禁止物理删除,请置 enabled=0 停用"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_compiler_version_update.dspy —— 显式拒绝 CRUD 直写(M3b QC #2/#3 格式统一)。
# 版本行一经登记即为不可变基线(版本 diff / 确定性验证以其 rules_hash 为准),
# 变更只能登记新版本行,禁止原地 update 语义化版本或指纹字段。
debug('pbl_compiler_version_update.dspy: REJECTED direct write params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "编译器版本行不可变,请登记新版本(契约 pbl_compiler_version_register)",
"data": {
"widgettype": "Error",
"options": {"label": "编译器版本行不可变,请登记新版本(契约 pbl_compiler_version_register)"},
},
}

View File

@ -0,0 +1,13 @@
# pbl_game_definition_create.dspy —— 编译产物只读:显式拒绝手工新增(M3b QC #2 方案 A)。
# 唯一写入入口:pbl_compiler.gd_rules_writer.write_gd_rules()(由 pbl_compiler_compile 主流程调用)。
# 本端点存在的意义:让 xls2ui 不再回退生成默认 add_pbl_game_definition.dspy,
# 从而杜绝绕过 gd_rules_writer / rules_hash(29.6 确定性指纹) 的手工改写路径。
debug('pbl_game_definition_create.dspy: REJECTED write attempt params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "编译产物只读,禁止手工增删改",
"data": {
"widgettype": "Error",
"options": {"label": "编译产物只读,禁止手工增删改"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_game_definition_delete.dspy —— 编译产物只读:显式拒绝手工删除(M3b QC #2 方案 A)。
# 历史编译产物是版本 diff(pbl_compiler_version_diff / pbl_compiler_rules_diff)的比对基线,
# 删除即丢失可追溯性;清理只能由运维侧走带审计的迁移脚本,不走 CRUD。
debug('pbl_game_definition_delete.dspy: REJECTED write attempt params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "编译产物只读,禁止手工增删改",
"data": {
"widgettype": "Error",
"options": {"label": "编译产物只读,禁止手工增删改"},
},
}

View File

@ -0,0 +1,12 @@
# pbl_game_definition_update.dspy —— 编译产物只读:显式拒绝手工更新(M3b QC #2 方案 A)。
# rules_json / rules_hash / rule_count / rules_manifest 四件套只能由 gd_rules_writer 写入,
# 手工 update 会破坏 29.6 确定性指纹与版本可比性,故一律返回 Error。
debug('pbl_game_definition_update.dspy: REJECTED write attempt params=%s' % repr(params_kw))
return {
"status": "fail",
"message": "编译产物只读,禁止手工增删改",
"data": {
"widgettype": "Error",
"options": {"label": "编译产物只读,禁止手工增删改"},
},
}