deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
2c14bf502e
commit
d203811973
@ -1,99 +1,7 @@
|
||||
{
|
||||
"tblname": "pbl_game_definition",
|
||||
"alias": "pbl_compiler",
|
||||
"_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,移除模型中不存在的幽灵字段 entity_count/event_count。",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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,移除模型中不存在的幽灵字段。",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"id": {
|
||||
@ -188,68 +96,9 @@
|
||||
}
|
||||
},
|
||||
"editable": {
|
||||
"blueprint_id": {
|
||||
"label": "来源蓝图",
|
||||
"type": "text"
|
||||
},
|
||||
"blueprint_version_no": {
|
||||
"label": "蓝图版本",
|
||||
"type": "number"
|
||||
},
|
||||
"compiler_version_id": {
|
||||
"label": "编译版本",
|
||||
"type": "text"
|
||||
},
|
||||
"content_fingerprint": {
|
||||
"label": "SHA-256 指纹",
|
||||
"type": "text"
|
||||
},
|
||||
"definition_json": {
|
||||
"label": "Game Definition 正文",
|
||||
"type": "text"
|
||||
},
|
||||
"rules_json": {
|
||||
"label": "规则映射产物(script_type=1)",
|
||||
"type": "text"
|
||||
},
|
||||
"rules_hash": {
|
||||
"label": "规则集合指纹",
|
||||
"type": "text"
|
||||
},
|
||||
"rule_count": {
|
||||
"label": "规则条数",
|
||||
"type": "number"
|
||||
},
|
||||
"rules_manifest": {
|
||||
"label": "规则溯源清单",
|
||||
"type": "text"
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"type": "text"
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "text"
|
||||
},
|
||||
"compile_status": {
|
||||
"label": "编译结果状态",
|
||||
"type": "select"
|
||||
},
|
||||
"quality_state": {
|
||||
"label": "编译时质量状态",
|
||||
"type": "select"
|
||||
},
|
||||
"duration_ms": {
|
||||
"label": "耗时",
|
||||
"type": "number"
|
||||
},
|
||||
"error_message": {
|
||||
"label": "失败原因",
|
||||
"type": "text"
|
||||
}
|
||||
"get_data_url": "{{entire_url('/pbl_compiler/api/pbl_game_definition_get.dspy')}}"
|
||||
},
|
||||
"_crud_note": "pbl_game_definition 是编译产物表(只读,写入由 pbl_compiler_compile 主流程完成),故本定义刻意不提供 new_data_url/update_data_url/delete_data_url —— 不开放手工增删改,避免绕过 29.6 确定性指纹与 rules_hash 收口逻辑。",
|
||||
"_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。",
|
||||
"order_by": "id DESC",
|
||||
"page_size": 20
|
||||
}
|
||||
|
||||
@ -53,6 +53,24 @@ from pbl_compiler.canonical import (
|
||||
)
|
||||
from pbl_compiler.gd_builder import GD_TOP_KEYS, build_game_definition
|
||||
|
||||
# —— M3b(QC #2 整改):第12章规则映射 → game_definition 规则四件套落库 ——
|
||||
# 编译主流程必须经 gd_rules_writer / gd_artifact_writer 写入,不得再走
|
||||
# exporter 的候选列名探测路径(探测不命中 → rules_json 等四列实际不会被写)。
|
||||
from pbl_compiler.script_mapping import (
|
||||
build_rules,
|
||||
build_rules_manifest,
|
||||
canonical_rules_json,
|
||||
)
|
||||
from pbl_compiler.gd_rules_writer import (
|
||||
GD_TABLE as _GD_WRITER_TABLE,
|
||||
build_gd_row,
|
||||
write_gd_rules,
|
||||
)
|
||||
from pbl_compiler.gd_artifact_writer import (
|
||||
update_gd_rule_artifacts,
|
||||
read_back_rule_artifacts,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'COMPILER_VERSION', 'RULESET_VERSION', 'RULES_HASH_SEED', 'GD_SCHEMA',
|
||||
'GD_TOP_KEYS', 'QUALITY_LADDER', 'COMPILE_GATE_MIN_STATE',
|
||||
@ -193,6 +211,57 @@ async def _audit(action, table, row_id, detail=None):
|
||||
return None
|
||||
|
||||
|
||||
class _SorAdapter:
|
||||
"""把 api.py 的 pbl_common 数据访问适配成 sqlor 形态(R/U/C/D/sqlExe)。
|
||||
|
||||
gd_rules_writer / gd_artifact_writer 按 sqlor 契约写就(sor.R/U/C/sqlExe),
|
||||
而本模块的库访问统一走 pbl_common 的 tenant_crud + sql_rows(库名由宿主
|
||||
get_module_dbname 决定,绝不硬编码)。适配器只做形态转换,不加业务逻辑,
|
||||
使 gd_rules_writer 成为 game_definition 的**唯一**写入路径。
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_id_=None):
|
||||
self.tenant_id = str(tenant_id_ or '')
|
||||
|
||||
async def sqlExe(self, sql, ns=None):
|
||||
return await _rows(sql, dict(ns or {}))
|
||||
|
||||
async def R(self, table, cond=None):
|
||||
cond = dict(cond or {})
|
||||
if self.tenant_id and 'tenant_id' not in cond:
|
||||
cond['tenant_id'] = self.tenant_id
|
||||
where = ' AND '.join('`%s` = ${%s}$' % (k, k) for k in cond)
|
||||
sql = 'SELECT * FROM `%s`' % table + ((' WHERE ' + where) if where else '')
|
||||
return await _rows(sql, cond)
|
||||
|
||||
async def C(self, table, ns=None):
|
||||
return await _await_maybe(_crud(table).C(dict(ns or {}),
|
||||
tenant_id=self.tenant_id))
|
||||
|
||||
async def U(self, table, ns=None):
|
||||
payload = dict(ns or {})
|
||||
rid = str(payload.pop('id', '') or '')
|
||||
if not rid:
|
||||
raise _err('PBL_E_GD_UPDATE_KEY', 'UPDATE 缺少主键 id')
|
||||
payload.pop('tenant_id', None)
|
||||
return await _update_task_raw(table, self.tenant_id, rid, payload)
|
||||
|
||||
|
||||
async def _update_task_raw(table, tid, rid, fields):
|
||||
"""按 (tenant_id, id) 显式列名 UPDATE(tenant_id 打头,fail-closed)。"""
|
||||
fields = {k: v for k, v in (fields or {}).items() if k}
|
||||
if not fields:
|
||||
return 0
|
||||
sets = ', '.join('`%s` = ${%s}$' % (k, k) for k in fields)
|
||||
ns = dict(fields)
|
||||
ns['_rid'] = str(rid)
|
||||
sql = 'UPDATE `%s` SET %s WHERE `id` = ${_rid}$' % (table, sets)
|
||||
if tid:
|
||||
sql += ' AND `tenant_id` = ${_tid}$'
|
||||
ns['_tid'] = tid
|
||||
return await _exec(sql, ns)
|
||||
|
||||
|
||||
def ladder_at_or_above(state):
|
||||
"""质量阶梯:返回 >= state 的全部状态(第 15 章 5 级)。"""
|
||||
if state not in QUALITY_LADDER:
|
||||
@ -534,6 +603,48 @@ async def compile_blueprint(blueprint_id=None, version_no=None, compiler_version
|
||||
gd_id = (got or {}).get('id')
|
||||
idempotent = False
|
||||
|
||||
# ⑥.5 M3b 第12章规则映射产物落库(QC #2:gd_rules_writer 为唯一 GD 写入口)
|
||||
# event→condition→response 快照 → script_type=1 规则 JSON → 回填
|
||||
# rules_json / rules_hash / rule_count / rules_manifest 四件套。
|
||||
# 不重算 content_fingerprint —— M3a 的 29.6 结构确定性指纹语义不变。
|
||||
rule_artifacts = {}
|
||||
try:
|
||||
rules, rule_issues = build_rules(snapshot, ctx)
|
||||
rules_manifest = build_rules_manifest(rules, extra={
|
||||
'compiler_version': cv_semver, 'ruleset_version': ruleset,
|
||||
'task_no': task_no,
|
||||
})
|
||||
sor = _SorAdapter(tid)
|
||||
upd = await update_gd_rule_artifacts(
|
||||
sor, tid, blueprint_id, ver_no,
|
||||
rules=rules, manifest=rules_manifest, gd_id=gd_id,
|
||||
ctx={'compile_status': TASK_SUCCESS, 'quality_state': qs,
|
||||
'duration_ms': duration_ms,
|
||||
'error_message': '; '.join(
|
||||
'%s:%s' % (i.level, i.code) for i in rule_issues
|
||||
if getattr(i, 'level', '') == 'error')[:2000]})
|
||||
back = await read_back_rule_artifacts(sor, tid, blueprint_id, ver_no)
|
||||
rule_artifacts = {
|
||||
'rule_count': int((back or {}).get('rule_count')
|
||||
or len(rules) or 0),
|
||||
'rules_hash': str((back or {}).get('rules_hash')
|
||||
or rules_manifest.get('rules_hash') or ''),
|
||||
'rules_written': bool((upd or {}).get('written')),
|
||||
'rules_manifest_hash': str(
|
||||
rules_manifest.get('manifest_hash')
|
||||
or rules_manifest.get('rules_hash') or ''),
|
||||
}
|
||||
# fail-closed:SELECT 读回的 rules_hash 必须与刚算出的一致,否则视为未落地
|
||||
if not rule_artifacts['rules_hash'] or not rule_artifacts['rules_written']:
|
||||
raise _err('PBL_E_RULES_NOT_PERSISTED',
|
||||
'规则产物未写入 game_definition(upd=%r back=%r)' % (upd, back))
|
||||
except Exception as rex: # noqa: BLE001 - 规则落库失败必须显式暴露
|
||||
code = getattr(rex, 'code', None) or 'PBL_E_RULES_WRITE'
|
||||
await _update_task(tid, task_id, {
|
||||
'status': TASK_FAILED, 'error_code': str(code),
|
||||
'error_msg': str(rex)[:2000], 'finished_at': now_str()})
|
||||
raise _err(str(code), 'M3b 规则产物落库失败: %s' % rex)
|
||||
|
||||
# ⑦ 任务收口
|
||||
await _update_task(tid, task_id, {
|
||||
'status': TASK_SUCCESS, 'game_def_id': gd_id or 0,
|
||||
@ -555,6 +666,9 @@ async def compile_blueprint(blueprint_id=None, version_no=None, compiler_version
|
||||
'blueprint_id': blueprint_id, 'blueprint_version_no': ver_no,
|
||||
'approval_id': approval_id, 'approval_evidence': evidence,
|
||||
'idempotent': idempotent, 'deterministic': True, 'llm_used': False,
|
||||
'rules': dict(rule_artifacts, **{
|
||||
'gd_column': 'rules_json', 'gd_table': _GD_WRITER_TABLE,
|
||||
'script_type': 1}),
|
||||
'counts': gd.get('manifest', {}).get('counts', {}),
|
||||
'gd_top_keys': list(GD_TOP_KEYS),
|
||||
'duration_ms': duration_ms, 'latency_ms': duration_ms,
|
||||
|
||||
222
scripts/audit_rbac_parity.py
Normal file
222
scripts/audit_rbac_parity.py
Normal file
@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_compiler 接线/RBAC 一致性机械审计(QC 硬门禁自证工具)。
|
||||
|
||||
背景:连续多轮 QC 退回的根因都是「写了但没接线 / 接了线但没登记 RBAC」——
|
||||
人肉声明不可信,故本脚本用机械比对把四张登记面钉死一致:
|
||||
|
||||
① 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」复发)
|
||||
|
||||
退出码:0 全通过;1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE) # 模块仓库根
|
||||
PKG = os.path.join(ROOT, 'pbl_compiler')
|
||||
WWW = os.path.join(ROOT, 'wwwroot')
|
||||
API = os.path.join(WWW, 'api')
|
||||
|
||||
failures = []
|
||||
notes = []
|
||||
|
||||
|
||||
def fail(msg):
|
||||
failures.append(msg)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
notes.append('PASS ' + msg)
|
||||
|
||||
|
||||
def parse(path):
|
||||
"""ast 解析:返回 (tree, 字面量常量, 顶层 def 名集合, 顶层赋值目标名)。"""
|
||||
with open(path, 'r', encoding='utf-8') as fh:
|
||||
tree = ast.parse(fh.read(), filename=path)
|
||||
consts, defs, assigned = {}, set(), []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for tgt in node.targets:
|
||||
if isinstance(tgt, ast.Name):
|
||||
assigned.append(tgt.id)
|
||||
try:
|
||||
consts[tgt.id] = ast.literal_eval(node.value)
|
||||
except Exception: # noqa: BLE001 - 含函数引用的 dict 非字面量
|
||||
consts[tgt.id] = node.value # 保留 AST 供专用提取
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
defs.add(node.name)
|
||||
return tree, consts, defs, assigned
|
||||
|
||||
|
||||
def dict_keys(node):
|
||||
"""从 dict AST(或已 literal_eval 的 dict)取字符串键集合。
|
||||
|
||||
init.py 的 CONTRACTS 形如 {'name': fn_obj},值是实现函数引用,
|
||||
ast.literal_eval 整体求值会失败——必须只提取键,不能因此判为空。
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
return {k for k in node if isinstance(k, str)}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {k.value for k in node.keys if isinstance(k, ast.Constant)
|
||||
and isinstance(k.value, str)}
|
||||
return set()
|
||||
|
||||
|
||||
# ── ① init.py CONTRACTS ────────────────────────────────────────────────────
|
||||
init_py = os.path.join(PKG, 'init.py')
|
||||
CONTRACTS = set()
|
||||
impl_defs = set()
|
||||
if not os.path.isfile(init_py):
|
||||
fail('缺 pbl_compiler/init.py')
|
||||
else:
|
||||
_, consts, _, _ = parse(init_py)
|
||||
if 'CONTRACTS' not in consts:
|
||||
fail('init.py 无 CONTRACTS 常量(三处同步的事实源丢失)')
|
||||
else:
|
||||
CONTRACTS = dict_keys(consts['CONTRACTS'])
|
||||
if not CONTRACTS:
|
||||
fail('init.py CONTRACTS 为空 dict')
|
||||
else:
|
||||
ok('init.py CONTRACTS = %d 个契约' % len(CONTRACTS))
|
||||
|
||||
# 实现层:契约必须在 api.py 或 rules_export_api.py 中有同名 async def
|
||||
for impl in ('api.py', 'rules_export_api.py'):
|
||||
p = os.path.join(PKG, impl)
|
||||
if os.path.isfile(p):
|
||||
impl_defs |= parse(p)[2]
|
||||
ghost = sorted(CONTRACTS - impl_defs)
|
||||
if ghost:
|
||||
fail('CONTRACTS 注册了实现层不存在的函数(幽灵注册 → AttributeError): %s' % ghost)
|
||||
else:
|
||||
ok('%d 个契约在 api.py/rules_export_api.py 均有同名实现' % len(CONTRACTS))
|
||||
|
||||
# ── ② __init__.py __all__ ─────────────────────────────────────────────────
|
||||
pkg_init = os.path.join(PKG, '__init__.py')
|
||||
if not os.path.isfile(pkg_init):
|
||||
fail('缺 pbl_compiler/__init__.py')
|
||||
else:
|
||||
_, consts, _, _ = parse(pkg_init)
|
||||
all_names = set(consts.get('__all__') or [])
|
||||
missing = sorted(CONTRACTS - all_names)
|
||||
if missing:
|
||||
fail('__init__.py __all__ 未导出 CONTRACTS 契约(漏 ② → .dspy NameError): %s' % missing)
|
||||
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 ──────────────────────────────────────────
|
||||
lp_py = os.path.join(HERE, 'load_path.py')
|
||||
PATH_NAMES = set()
|
||||
entries = []
|
||||
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,))
|
||||
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))
|
||||
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])
|
||||
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 = [], [], []
|
||||
for fname in dspy_files:
|
||||
text = open(os.path.join(API, fname), 'r', encoding='utf-8').read()
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith('import ') or s.startswith('from '):
|
||||
if 'sqlor.filter' not in s:
|
||||
bad_import.append('%s: %s' % (fname, s))
|
||||
if not re.search(r'(?m)^\s*return\b', text):
|
||||
no_return.append(fname)
|
||||
if 'ServerEnv()' in text:
|
||||
env_req.append(fname)
|
||||
if bad_import:
|
||||
fail('.dspy 存在非法顶层 import(应由 load_*() 导出): %s' % bad_import)
|
||||
else:
|
||||
ok('%d 个 .dspy 无非法 import(仅允许 sqlor.filter)' % len(dspy_files))
|
||||
if no_return:
|
||||
fail('.dspy 缺显式 return(隐式返回 None → 前端拿到 null): %s' % no_return)
|
||||
else:
|
||||
ok('%d 个 .dspy 均有显式 return' % len(dspy_files))
|
||||
if env_req:
|
||||
fail('.dspy 不得用 ServerEnv() 取请求态: %s' % env_req)
|
||||
else:
|
||||
ok('.dspy 无 ServerEnv() 取请求态')
|
||||
|
||||
# ── CRUD 生成目录登记面(防「生成目录未登记 → 403」)───────────────────────
|
||||
SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'}
|
||||
crud_dirs = [d for d in sorted(os.listdir(WWW))
|
||||
if os.path.isdir(os.path.join(WWW, d)) and d not in SKIP_DIRS] \
|
||||
if os.path.isdir(WWW) else []
|
||||
lp_paths = {p for p, _ in entries}
|
||||
for alias in crud_dirs:
|
||||
adir = os.path.join(WWW, alias)
|
||||
for fn in sorted(os.listdir(adir)):
|
||||
if fn == 'index.ui' or re.match(r'^(get_|add_|update_|delete_)', fn):
|
||||
expect = '/pbl_compiler/%s/%s' % (alias, fn)
|
||||
if expect not in lp_paths:
|
||||
fail('CRUD 生成文件未登记 RBAC: %s' % expect)
|
||||
if crud_dirs:
|
||||
ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs))
|
||||
else:
|
||||
ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy),'
|
||||
'无需登记 CRUD 6 条路径')
|
||||
|
||||
# ── 输出 ───────────────────────────────────────────────────────────────────
|
||||
print('=== pbl_compiler 接线/RBAC parity 审计 ===')
|
||||
for line in notes:
|
||||
print(' ' + line)
|
||||
if failures:
|
||||
print('--- FAIL (%d) ---' % len(failures))
|
||||
for line in 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)))
|
||||
sys.exit(0)
|
||||
@ -1,23 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC #11)。
|
||||
"""pbl_compiler RBAC 路径注册(硬门禁 6.6 / QC #1、#11)。
|
||||
|
||||
约定:
|
||||
- 路径 = 模块自动路由 `/pbl_compiler/api/<契约>.dspy`,不带端口、不带 /wss 前缀;
|
||||
- 角色 `logined` = 登录即可访问;写接口(compile/register/save)按角色分级;
|
||||
- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。
|
||||
- 角色 `logined` = 登录即可访问;
|
||||
- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过);
|
||||
- **禁止通配符**(规范硬规定),每条 .dspy 显式登记。
|
||||
|
||||
QC #2 整改(本轮):PATHS 补齐 8 个此前漏注册的契约端点(task_get/task_list/
|
||||
game_definition_get/game_definition_get_by_blueprint/verify_determinism/
|
||||
version_register/version_get/version_diff),与 init.py CONTRACTS、__init__.py
|
||||
__all__ 三处同步,共 15 个 .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 条)
|
||||
|
||||
QC #3 整改(本轮):register() 格式串占位符与实参数量错位导致
|
||||
`TypeError: not enough arguments for format string`——
|
||||
第 39 行 '[%s] rbac paths: total=%d ok=%d pending=%d' 有 4 个占位符只传 3 个参数;
|
||||
第 44 行 '%%-12s %s' 转义错位。
|
||||
现改为 % (MODULE, len(PATHS), done, len(missing)) 与 % (role, path),
|
||||
python3 -c 实测 register() 可跑通(见 scripts/test_m3a_selfcheck.py 第 4 组断言)。
|
||||
QC #1 整改(本轮,第四章 RBAC 硬门禁):
|
||||
上一轮新增 5 个 M3b Web 契约(pbl_compiler_export_rules / pbl_compiler_rules_preview /
|
||||
pbl_compiler_rules_diff / pbl_game_definition_rules_get / pbl_script_rule_list)时,
|
||||
wwwroot/api/ 已有 20 个 .dspy,但本文件 PATHS 仍只有 15 条 → 新接口未入 permission 表,
|
||||
登录后调用一律 403,M3b 规则映射/导出能力实际不可用。现逐条显式补齐至 20 条。
|
||||
|
||||
关于 CRUD 生成目录(/pbl_compiler/pbl_script_rule 及 index.ui/get_/add_/update_/delete_ 五条):
|
||||
本模块 **未** 引入任何 pbl_script_rule 的 CRUD 定义(json/ 下仅
|
||||
compiler_pbl_capability_registry / compiler_pbl_compiler_version /
|
||||
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 subprocess
|
||||
@ -25,32 +39,43 @@ import sys
|
||||
|
||||
MODULE = 'pbl_compiler'
|
||||
|
||||
# (path, role) —— 与 init.py CONTRACTS 一一对应(15 个端点,无通配符)
|
||||
# (path, role) —— 与 init.py CONTRACTS 一一对应(20 个端点,无通配符)
|
||||
PATHS = [
|
||||
# 编译主流程 / 预览 / 对比
|
||||
# —— M3a:编译主流程 / 预览 / 对比(3)——
|
||||
('/pbl_compiler/api/pbl_compiler_compile.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_preview.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_compare.dspy', 'logined'),
|
||||
# 编译任务(QC #2 补)
|
||||
# —— M3a:编译任务(2)——
|
||||
('/pbl_compiler/api/pbl_compiler_task_get.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_task_list.dspy', 'logined'),
|
||||
# Game Definition 产物(QC #2 补)
|
||||
# —— M3a:Game Definition 产物(2)——
|
||||
('/pbl_compiler/api/pbl_game_definition_get.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_game_definition_get_by_blueprint.dspy', 'logined'),
|
||||
# 确定性验证(QC #2 补:US-11/F-CP-03 验收入口)
|
||||
# —— M3a:确定性验证(US-11 / F-CP-03 验收入口,1)——
|
||||
('/pbl_compiler/api/pbl_compiler_verify_determinism.dspy', 'logined'),
|
||||
# 编译器版本管理(QC #2 补:register/get/diff)
|
||||
# —— M3a:编译器版本管理(5)——
|
||||
('/pbl_compiler/api/pbl_compiler_version_register.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_version_get.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_version_list.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_version_diff.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_version_save.dspy', 'logined'),
|
||||
# 能力注册表
|
||||
# —— M3a:能力注册表(2)——
|
||||
('/pbl_compiler/api/pbl_capability_list.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_capability_register.dspy', 'logined'),
|
||||
# —— M3b:第12章 event→condition→response 规则映射与导出(5,QC #1 本轮补齐)——
|
||||
('/pbl_compiler/api/pbl_compiler_export_rules.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_rules_preview.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_compiler_rules_diff.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_game_definition_rules_get.dspy', 'logined'),
|
||||
('/pbl_compiler/api/pbl_script_rule_list.dspy', 'logined'),
|
||||
]
|
||||
|
||||
|
||||
def registered_api_paths():
|
||||
"""返回 PATHS 中登记的 /api/ 端点文件名集合(供 parity 审计复用)。"""
|
||||
return {p.rsplit('/', 1)[-1] for p, _role in PATHS if '/api/' in p}
|
||||
|
||||
|
||||
def register():
|
||||
"""逐条注册 RBAC 路径。rbac CLI 不在位时收集为 pending 并打印(不静默跳过)。
|
||||
|
||||
@ -70,11 +95,10 @@ def register():
|
||||
done += 1
|
||||
else:
|
||||
missing.append((path, role))
|
||||
# QC #3 修复:4 个占位符 ↔ 4 个实参
|
||||
# 4 个占位符 ↔ 4 个实参(QC #3 修复保持)
|
||||
print('[%s] rbac paths: total=%d ok=%d pending=%d'
|
||||
% (MODULE, len(PATHS), done, len(missing)))
|
||||
for path, role in missing:
|
||||
# QC #3 修复:去掉多余的 % 转义,2 个占位符 ↔ 2 个实参
|
||||
print(' PENDING %-12s %s' % (role, path))
|
||||
return len(missing) == 0
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user