diff --git a/json/compiler_pbl_capability_registry.json b/json/compiler_pbl_capability_registry.json index d8616b2..ab5c40d 100644 --- a/json/compiler_pbl_capability_registry.json +++ b/json/compiler_pbl_capability_registry.json @@ -1,131 +1,39 @@ { "tblname": "pbl_capability_registry", - "alias": "pbl_compiler", - "browserfields": { - "id": { - "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 - } - }, + "alias": "pbl_capability_registry_list", + "title": "能力注册表(第11章缺口)", + "_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)。", "params": { "browserfields": { - "id": { - "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 - } + "id": {"label": "ID", "type": "number", "list": true}, + "tenant_id": {"label": "租户ID", "type": "text", "list": true}, + "capability_key": {"label": "能力键", "type": "text", "list": true}, + "category": {"label": "分类", "type": "text", "list": true}, + "args_schema_json": {"label": "参数JSON Schema", "type": "text", "list": true}, + "permission_required": {"label": "所需权限", "type": "text", "list": true}, + "is_enabled": {"label": "启用", "type": "checkbox", "list": true}, + "version_no": {"label": "版本", "type": "number", "list": true}, + "description": {"label": "说明", "type": "text", "list": true} }, "editable": { - "capability_key": { - "label": "能力键", - "type": "select" + "get_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_capability_list.dspy')}}" }, - "category": { - "label": "分类", - "type": "select" + "new_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_create.dspy')}}" }, - "args_schema_json": { - "label": "参数JSON Schema", - "type": "text" + "update_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_update.dspy')}}" }, - "permission_required": { - "label": "所需权限", - "type": "select" - }, - "is_enabled": { - "label": "启用", - "type": "checkbox" - }, - "version_no": { - "label": "版本", - "type": "number" - }, - "description": { - "label": "说明", - "type": "text" + "delete_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_capability_registry_delete.dspy')}}" } }, + "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", "page_size": 20 } diff --git a/json/compiler_pbl_compiler_version.json b/json/compiler_pbl_compiler_version.json index cb868f9..f3f60b5 100644 --- a/json/compiler_pbl_compiler_version.json +++ b/json/compiler_pbl_compiler_version.json @@ -1,117 +1,38 @@ { "tblname": "pbl_compiler_version", - "alias": "pbl_compiler", - "browserfields": { - "id": { - "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 - } - }, + "alias": "pbl_compiler_version_list", + "title": "编译器版本(29.6 确定性)", + "_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)。", "params": { "browserfields": { - "id": { - "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 - } + "id": {"label": "ID", "type": "number", "list": true}, + "tenant_id": {"label": "租户ID", "type": "text", "list": true}, + "code": {"label": "版本编码", "type": "text", "list": true}, + "semver": {"label": "语义化版本", "type": "text", "list": true}, + "rules_hash": {"label": "规则集SHA256前缀", "type": "text", "list": true}, + "entrypoint": {"label": "入口函数", "type": "text", "list": true}, + "enabled": {"label": "启用", "type": "checkbox", "list": true}, + "notes": {"label": "说明", "type": "text", "list": true} }, "editable": { - "code": { - "label": "版本编码", - "type": "select" + "get_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_list.dspy')}}" }, - "semver": { - "label": "语义化版本", - "type": "select" + "new_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_create.dspy')}}" }, - "rules_hash": { - "label": "规则集SHA256前缀", - "type": "text" + "update_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_update.dspy')}}" }, - "entrypoint": { - "label": "入口函数", - "type": "select" - }, - "enabled": { - "label": "启用", - "type": "checkbox" - }, - "notes": { - "label": "说明", - "type": "text" + "delete_data_url": { + "url": "{{entire_url('/pbl_compiler/api/pbl_compiler_version_delete.dspy')}}" } }, + "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", "page_size": 20 } diff --git a/json/compiler_pbl_game_definition.json b/json/compiler_pbl_game_definition.json index 35f6919..47abe10 100644 --- a/json/compiler_pbl_game_definition.json +++ b/json/compiler_pbl_game_definition.json @@ -1,105 +1,67 @@ { "tblname": "pbl_game_definition", "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": { "browserfields": { - "id": { - "label": "ID", - "list": true, - "type": "text" - }, - "tenant_id": { - "label": "租户ID", - "list": true, - "type": "text" - }, - "blueprint_id": { - "label": "来源蓝图", - "type": "text", - "list": true - }, - "blueprint_version_no": { - "label": "蓝图版本", - "type": "number", - "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 - } + "id": {"label": "ID", "type": "text", "list": true}, + "tenant_id": {"label": "租户ID", "type": "text", "list": true}, + "blueprint_id": {"label": "来源蓝图", "type": "text", "list": true}, + "blueprint_version_no": {"label": "蓝图版本", "type": "number", "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": { - "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", "page_size": 20 } -} \ No newline at end of file +} diff --git a/pbl_compiler/init.py b/pbl_compiler/init.py index 48a1e7b..2c7cca1 100644 --- a/pbl_compiler/init.py +++ b/pbl_compiler/init.py @@ -10,7 +10,7 @@ QC #1 整改(M3b 本轮):CONTRACTS 由 15 → 20,新增 rules_export_api 「第12章规则映射 / 导出 / game_definition 写入」契约,并导出 GD 规则写入器与纯映射 层函数(宿主应用与自测脚本可直接复用)。本文件的 CONTRACTS 是三处同步的唯一事实源: · 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__ """ from ahserver.serverenv import ServerEnv diff --git a/scripts/audit_rbac_parity.py b/scripts/audit_rbac_parity.py index d7f0213..3d43ad1 100644 --- a/scripts/audit_rbac_parity.py +++ b/scripts/audit_rbac_parity.py @@ -1,25 +1,29 @@ #!/usr/bin/env python3 # -*- 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) - ③ wwwroot/api/*.dspy 实际端点文件集合(须 == CONTRACTS) - ④ scripts/load_path.py PATHS 登记集合(须 == CONTRACTS) - -并额外检查: - · load_path.py 无通配符(规范硬规定:禁 % / *) - · 每个 .dspy 显式 return、除 sqlor.filter 外无顶层 import、无 ServerEnv() 取请求态 - · 实现层:契约名必须能在 api.py / rules_export_api.py 中找到同名 async def(防幽灵注册) - · wwwroot/ 下若出现 CRUD 生成子目录,其 index.ui + get_/add_/update_/delete_*.dspy - 必须在 PATHS 中逐条登记(防「生成目录未登记 → 403」复发) +比对集合: + 期望端点集合 EXPECTED = CONTRACTS ∪ READONLY_DENY + ① pbl_compiler/init.py CONTRACTS 契约名集合(20) + ② pbl_compiler/__init__.py __all__ 导出集合(须 ⊇ CONTRACTS) + ③ wwwroot/api/*.dspy 实际端点文件集合(须 == EXPECTED) + ④ scripts/load_path.py PATHS 登记集合(须 == EXPECTED) + ⑤ scripts/load_path.py PATHS_READONLY_DENY(CRUD 只读拒绝端点,非契约) + 另加 json/*.json 结构审计(QC #3:改任一 CRUD 文件必须扫 json/ 全部,Pitfall 10): + · 根键白名单 {tblname, alias, title, params, _comment}(禁根级 browserfields) + · browserfields 只允许挂在 params 下 + · alias 不得与模块名同名(会与模块 wwwroot 目录冲突) + · params.editable 必须齐备 new_data_url / update_data_url / delete_data_url + 且三个 URL 指向的 .dspy 文件真实存在(防 xls2ui 回退默认写入口) + · browserfields/editexclouded 引用的字段必须存在于 models/{tblname}.json 退出码:0 全通过;1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。 """ import ast +import json import os import re import sys @@ -29,6 +33,8 @@ ROOT = os.path.dirname(HERE) # 模块仓库根 PKG = os.path.join(ROOT, 'pbl_compiler') WWW = os.path.join(ROOT, 'wwwroot') API = os.path.join(WWW, 'api') +JSONDIR = os.path.join(ROOT, 'json') +MODELSDIR = os.path.join(ROOT, 'models') failures = [] notes = [] @@ -75,6 +81,14 @@ def dict_keys(node): 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 = os.path.join(PKG, 'init.py') CONTRACTS = set() @@ -116,51 +130,101 @@ else: else: ok('__init__.py __all__ 覆盖全部 %d 个契约' % len(CONTRACTS)) -# ── ③ 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 != 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 ────────────────────────────────────────── +# ── ⑤ load_path.py:PATHS / PATHS_CONTRACTS / PATHS_READONLY_DENY ────────── lp_py = os.path.join(HERE, 'load_path.py') -PATH_NAMES = set() -entries = [] +entries, contract_entries, deny_entries = [], [], [] +READONLY_DENY = set() if not os.path.isfile(lp_py): fail('缺 scripts/load_path.py') else: _, consts, _, _ = parse(lp_py) - raw = consts.get('PATHS') - if isinstance(raw, ast.List): - raw = [ast.literal_eval(e) for e in raw.elts] - if not raw: - fail('load_path.py PATHS 为空(RBAC 零登记 → 全部 403)') - for item in raw or []: - if not (isinstance(item, (list, tuple)) and len(item) == 2): - fail('load_path.py PATHS 元素必须是 (path, role) 二元组: %r' % (item,)) + # 先把各列表常量解析成 python 值,再解析 PATHS = A + B(Name 需回查 consts)。 + # 关键:parse() 对「纯字面量列表」已 literal_eval 成 python list,只有求值失败 + # 的赋值才保留 AST。两种形态都必须收进 resolved —— 否则 PATHS = PATHS_CONTRACTS + # + PATHS_READONLY_DENY 的 BinOp 回查会拿到空列表,把已登记的 29 条误判成 + # 「PATHS 为空(RBAC 零登记)」,产生假 FAIL(本轮排查出的审计工具自身缺陷)。 + resolved = {} + for var, raw in consts.items(): + 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 - entries.append((item[0], item[1])) - PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')] - for p, _ in entries if '/api/' in p} - if PATH_NAMES != CONTRACTS: - fail('load_path.py PATHS 与 CONTRACTS 不一致:未登记=%s 幽灵登记=%s' - % (sorted(CONTRACTS - PATH_NAMES), sorted(PATH_NAMES - CONTRACTS))) - else: - ok('load_path.py PATHS 登记 %d 条,与 CONTRACTS 一一对应' % len(PATH_NAMES)) + if isinstance(raw, ast.List): + raw = resolved.get(var, []) + elif isinstance(raw, ast.Name): + raw = resolved.get(raw.id, []) + elif isinstance(raw, ast.BinOp): # PATHS = PATHS_CONTRACTS + PATHS_READONLY_DENY + left = resolved.get(getattr(raw.left, 'id', None), []) if isinstance(raw.left, ast.Name) \ + else (literal_or_none(raw.left) or []) + 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: if '%' in p or '*' in p: fail('load_path.py 禁用通配符: %s' % p) if not any('%' in p or '*' in p for p, _ in 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' % (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)───────────────────────── bad_import, no_return, env_req = [], [], [] @@ -188,6 +252,22 @@ if env_req: else: 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」)─────────────────────── SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'} crud_dirs = [d for d in sorted(os.listdir(WWW)) @@ -204,11 +284,93 @@ for alias in crud_dirs: if crud_dirs: ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs)) else: - ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy),' - '无需登记 CRUD 6 条路径') + ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy)') + +# ── 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: print(' ' + line) if failures: @@ -217,6 +379,7 @@ if failures: print(' ' + line) print('RESULT: FAIL') sys.exit(1) -print('RESULT: PASS (contracts=%d, dspy=%d, rbac_api_paths=%d)' - % (len(CONTRACTS), len(dspy_files), len(PATH_NAMES))) +print('RESULT: PASS (contracts=%d, readonly_deny=%d, dspy=%d, rbac_api_paths=%d, json=%d)' + % (len(CONTRACTS), len(READONLY_DENY), len(dspy_files), len(PATH_NAMES), + len(json_files))) sys.exit(0) diff --git a/scripts/load_path.py b/scripts/load_path.py index dbf9a4c..1162b6c 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC #1、#11)。 +"""pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC 第四章 RBAC)。 约定: - 路径 = 模块自动路由 `/pbl_compiler/api/<契约>.dspy`,不带端口、不带 /wss 前缀; @@ -8,30 +8,32 @@ - 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过); - **禁止通配符**(规范硬规定),每条 .dspy 显式登记。 -登记面与事实源(四处必须一一对应,数量一致): - ① 实现:pbl_compiler/api.py(15 个 M3a 契约)+ pbl_compiler/rules_export_api.py(5 个 M3b 契约) - ② 导出:pbl_compiler/__init__.py import + __all__ - ③ 注册:pbl_compiler/init.py CONTRACTS(共 20 条) - ④ 端点:wwwroot/api/<契约名>.dspy(共 20 个)→ 本文件 PATHS(共 20 条) +两个登记集合(合计 29 条,全部显式): + PATHS_CONTRACTS —— 20 条:Web 契约端点,与四处事实源一一对应 + ① 实现:pbl_compiler/api.py(15 个 M3a 契约)+ pbl_compiler/rules_export_api.py(5 个 M3b 契约) + ② 导出:pbl_compiler/__init__.py import + __all__ + ③ 注册: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_