271 lines
12 KiB
Python
271 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""pbl_compiler M3a 自检脚本(QC #1 整改:真实落盘,可执行)。
|
||
|
||
运行:
|
||
cd modules/pbl_compiler && python3 scripts/test_m3a_selfcheck.py
|
||
(或 python3 -m pytest scripts/test_m3a_selfcheck.py -q)
|
||
|
||
覆盖 QC 退回意见要求的断言组(全部纯函数/纯结构,不依赖真实 DB):
|
||
组1 canonical 确定性:同输入 → canonical_json 字节全等 + sha256 指纹全等(US-11/F-CP-03)
|
||
组2 GD 10 顶层键齐全(design 第 9 章)
|
||
组3 volatile 字段(createdAt/durationMs/taskNo/id...)不参与指纹(29.6 确定性)
|
||
组4 register() 可跑通不崩(QC #3:格式串占位符错位修复验证)
|
||
组5 三处同步(QC #2):__init__ 导出 == init.CONTRACTS == load_path.PATHS 契约名一致
|
||
组6 无硬编码 DB(QC #4):api.py 无 `DB = '...'` 常量,_dbname() 走 get_module_dbname
|
||
组7 浮点定点 + sort_keys:键序无关、浮点 6 位定点(canonical 规范化)
|
||
组8 registry_hash 确定性:同能力集 → 同 hash;增删能力 → hash 变化
|
||
|
||
退出码 0 = 全部通过;非 0 = 有断言失败(打印 FAIL 明细)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
import types
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MOD_ROOT = os.path.dirname(HERE)
|
||
sys.path.insert(0, MOD_ROOT) # pbl_compiler 包
|
||
sys.path.insert(0, os.path.join(os.path.dirname(MOD_ROOT), 'pbl_common')) # pbl_common 依赖
|
||
|
||
# ---- ahserver 桩(工作空间无平台运行时;仅为 import init.py 不崩)----
|
||
if 'ahserver.serverenv' not in sys.modules:
|
||
_ah = types.ModuleType('ahserver')
|
||
_se = types.ModuleType('ahserver.serverenv')
|
||
|
||
class _ServerEnv(object):
|
||
_inst = None
|
||
|
||
def __new__(cls):
|
||
if cls._inst is None:
|
||
cls._inst = super(_ServerEnv, cls).__new__(cls)
|
||
return cls._inst
|
||
|
||
_se.ServerEnv = _ServerEnv
|
||
_ah.serverenv = _se
|
||
sys.modules['ahserver'] = _ah
|
||
sys.modules['ahserver.serverenv'] = _se
|
||
|
||
from pbl_compiler import canonical as cn # noqa: E402
|
||
from pbl_compiler import gd_builder as gb # noqa: E402
|
||
from pbl_compiler import api as api # noqa: E402
|
||
from pbl_compiler import init as init_mod # noqa: E402
|
||
import pbl_compiler as pkg # noqa: E402
|
||
|
||
PASS, FAIL = [], []
|
||
|
||
|
||
def check(name, cond, detail=''):
|
||
(PASS if cond else FAIL).append(name)
|
||
print(' [%s] %s%s' % ('PASS' if cond else 'FAIL', name,
|
||
(' <- ' + detail) if (detail and not cond) else ''))
|
||
|
||
|
||
# 一个最小但真实的蓝图版本快照(含 7 类子对象若干 + 易变字段)
|
||
def _snapshot():
|
||
return {
|
||
'content': {
|
||
'project': [{'id': 'p1', 'title': '火星基地', 'summary': '建造可持续基地'}],
|
||
'problem': [{'id': 'q1', 'driving_question': '如何在火星自给自足?'}],
|
||
'learning_goals': [
|
||
{'id': 'g2', 'text': '掌握生态循环'},
|
||
{'id': 'g1', 'text': '理解能源约束'},
|
||
],
|
||
'missions': [{'id': 'm1', 'title': '着陆', 'order': 1}],
|
||
'roles': [{'id': 'r1', 'name': '工程师'}],
|
||
'scenes': [{'id': 's1', 'name': '基地外'}],
|
||
'entities': [{'id': 'e1', 'entity_key': 'solar_panel', 'scene_id': 's1'}],
|
||
'events': [{'id': 'ev1', 'event_key': 'power_on', 'entity_key': 'solar_panel'}],
|
||
'rubrics': [{'id': 'rb1', 'criterion': '可行性', 'weight': 0.5}],
|
||
}
|
||
}
|
||
|
||
|
||
def _ctx(i=0):
|
||
return {'blueprint_id': 101, 'blueprint_version_no': 3,
|
||
'compiler_version': '1.0.0', 'ruleset_version': 'pbl.rules.v1',
|
||
'rules_hash': 'deadbeefcafe1234',
|
||
'created_at': '2000-01-01T00:00:00Z', 'duration_ms': i,
|
||
'task_no': 'CT:101:3:1.0.0:%05d' % i}
|
||
|
||
|
||
def group1_canonical_determinism():
|
||
print('组1 canonical 确定性(同输入指纹全等)')
|
||
obj = {'b': [3, 1, 2], 'a': {'z': 1.5, 'y': 'x'}, 'n': None}
|
||
c1 = cn.canonical_json(obj, strip=False)
|
||
c2 = cn.canonical_json(obj, strip=False)
|
||
check('canonical_json 同输入字节全等', c1 == c2, '%r != %r' % (c1, c2))
|
||
f1, f2 = cn.sha256_fingerprint(obj), cn.sha256_fingerprint(obj)
|
||
check('sha256_fingerprint 同输入全等', f1 == f2 and len(f1) == 64, f1)
|
||
# 键序无关
|
||
obj2 = {'a': {'y': 'x', 'z': 1.5}, 'n': None, 'b': [3, 1, 2]}
|
||
check('键序不同 canonical 仍全等(sort_keys)',
|
||
cn.canonical_json(obj, strip=False) == cn.canonical_json(obj2, strip=False))
|
||
|
||
|
||
def group2_gd_top_keys():
|
||
print('组2 GD 10 顶层键齐全')
|
||
gd, fp = gb.build_game_definition(_snapshot(), _ctx(), [])
|
||
check('GD_TOP_KEYS 恰为 10 个', len(gb.GD_TOP_KEYS) == 10, str(gb.GD_TOP_KEYS))
|
||
missing = [k for k in gb.GD_TOP_KEYS if k not in gd]
|
||
check('GD 含全部 10 顶层键', not missing, 'missing=%s' % missing)
|
||
check('GD 指纹为 64 位 sha256', isinstance(fp, str) and len(fp) == 64, fp)
|
||
check('manifest.schema = pbl.game_definition.v1',
|
||
gd.get('manifest', {}).get('schema') == cn.GD_SCHEMA)
|
||
|
||
|
||
def group3_volatile_excluded():
|
||
print('组3 volatile 字段不参与指纹')
|
||
gd_a, fp_a = gb.build_game_definition(_snapshot(), _ctx(0), [])
|
||
gd_b, fp_b = gb.build_game_definition(_snapshot(), _ctx(999), [])
|
||
check('created_at/duration_ms/task_no 不同但指纹全等', fp_a == fp_b,
|
||
'%s != %s' % (fp_a, fp_b))
|
||
# 直接验证 strip_volatile 剔除易变键
|
||
dirty = {'createdAt': '2020', 'durationMs': 5, 'taskNo': 'X', 'id': 9,
|
||
'stable': 'keep'}
|
||
stripped = cn.strip_volatile(dirty)
|
||
leaked = [k for k in ('createdAt', 'durationMs', 'taskNo', 'id') if k in stripped]
|
||
check('strip_volatile 剔除全部易变键', not leaked and stripped.get('stable') == 'keep',
|
||
'leaked=%s stripped=%s' % (leaked, stripped))
|
||
for k in ('createdAt', 'created_at', 'durationMs', 'duration_ms',
|
||
'taskNo', 'task_no', 'id', 'timestamp'):
|
||
if k not in cn.VOLATILE_KEYS:
|
||
check('VOLATILE_KEYS 含 %s' % k, False)
|
||
return
|
||
check('VOLATILE_KEYS 覆盖时间/耗时/任务号/id', True)
|
||
|
||
|
||
def group4_register_runs():
|
||
print('组4 register() 可跑通不崩(QC #3)')
|
||
sys.path.insert(0, HERE)
|
||
import importlib
|
||
lp = importlib.import_module('load_path')
|
||
os.environ['RBAC_SET_PERM'] = '/nonexistent/set_role_perm.py' # 强制走 pending 分支
|
||
os.environ.pop('PY', None)
|
||
try:
|
||
ret = lp.register()
|
||
crashed = False
|
||
err = ''
|
||
except Exception as exc: # noqa: BLE001
|
||
crashed, ret, err = True, None, '%s: %s' % (type(exc).__name__, exc)
|
||
check('register() 不抛 TypeError(格式串占位符已对齐)', not crashed, err)
|
||
check('register() 返回 bool(pending 非空 → False)', isinstance(ret, bool), repr(ret))
|
||
check('PATHS 含 15 个端点', len(lp.PATHS) == 15, str(len(lp.PATHS)))
|
||
|
||
|
||
def group5_three_place_sync():
|
||
print('组5 三处同步(QC #2)')
|
||
api_contracts = set(n for n in api.__all__ if n.startswith('pbl_'))
|
||
init_contracts = set(init_mod.CONTRACTS.keys())
|
||
pkg_exports = set(n for n in pkg.__all__ if n.startswith('pbl_'))
|
||
sys.path.insert(0, HERE)
|
||
import importlib
|
||
lp = importlib.import_module('load_path')
|
||
rbac_names = set(os.path.basename(p).replace('.dspy', '') for p, _ in lp.PATHS)
|
||
|
||
check('init.CONTRACTS == api 契约(15 个)', init_contracts == api_contracts,
|
||
'only_api=%s only_init=%s' % (api_contracts - init_contracts,
|
||
init_contracts - api_contracts))
|
||
check('__init__ 导出 == init.CONTRACTS', pkg_exports == init_contracts,
|
||
'missing_export=%s' % (init_contracts - pkg_exports))
|
||
check('load_path.PATHS 契约名 == init.CONTRACTS', rbac_names == init_contracts,
|
||
'missing_rbac=%s extra_rbac=%s' % (init_contracts - rbac_names,
|
||
rbac_names - init_contracts))
|
||
# QC #2 点名的 8 个此前漏接线契约必须在三处都出现
|
||
eight = ['pbl_compiler_task_get', 'pbl_compiler_task_list',
|
||
'pbl_game_definition_get', 'pbl_game_definition_get_by_blueprint',
|
||
'pbl_compiler_verify_determinism', 'pbl_compiler_version_register',
|
||
'pbl_compiler_version_get', 'pbl_compiler_version_diff']
|
||
for name in eight:
|
||
ok = (name in pkg_exports and name in init_contracts and name in rbac_names
|
||
and callable(getattr(pkg, name, None)))
|
||
check('QC#2 契约 %s 三处齐全且可调用' % name, ok)
|
||
# verify_determinism 是 US-11 验收入口,必须可从包直接取到
|
||
check('US-11 入口 pbl_compiler_verify_determinism 可达',
|
||
callable(getattr(pkg, 'pbl_compiler_verify_determinism', None)))
|
||
|
||
|
||
def group6_no_hardcoded_db():
|
||
print('组6 无硬编码 DB(QC #4)')
|
||
src = io.open(os.path.join(MOD_ROOT, 'pbl_compiler', 'api.py'),
|
||
encoding='utf-8').read()
|
||
check("api.py 无 `DB = '...'` 常量", re.search(r"(?m)^DB\s*=\s*['\"]", src) is None)
|
||
check('api.py 无模块级 DB 属性', not hasattr(api, 'DB'))
|
||
check('_dbname() 走 get_module_dbname 解析', callable(api._dbname)
|
||
and isinstance(api._dbname(), str) and api._dbname())
|
||
# AST 扫描:模块级赋值不得出现 DB = 字面量
|
||
tree = ast.parse(src)
|
||
bad = []
|
||
for node in tree.body:
|
||
if isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name) and t.id == 'DB' \
|
||
and isinstance(node.value, ast.Constant) \
|
||
and isinstance(node.value.value, str):
|
||
bad.append(t.id)
|
||
check('AST:无模块级 DB = <字符串> 硬编码', not bad, str(bad))
|
||
# set_dbname 注入生效(宿主/测试可覆盖,证明非写死)
|
||
old = api._dbname()
|
||
api.set_dbname('pbl_injected')
|
||
injected = api._dbname()
|
||
api.set_dbname(None)
|
||
check('set_dbname 注入可覆盖库名', injected == 'pbl_injected', injected)
|
||
check('set_dbname(None) 回落解析值', api._dbname() == old, api._dbname())
|
||
|
||
|
||
def group7_float_and_sort():
|
||
print('组7 浮点定点 + 无空白分隔符')
|
||
c = cn.canonical_json({'x': 1.00000049, 'y': 2.5}, strip=False)
|
||
check('canonical 无空白分隔符', ' ' not in c and '\n' not in c, c)
|
||
check('浮点按 FLOAT_PRECISION 定点', cn.FLOAT_PRECISION == 6)
|
||
a = cn.canonical_json({'k': 0.1 + 0.2}, strip=False)
|
||
b = cn.canonical_json({'k': 0.3}, strip=False)
|
||
check('0.1+0.2 与 0.3 定点后全等', a == b, '%s vs %s' % (a, b))
|
||
|
||
|
||
def group8_registry_hash():
|
||
print('组8 registry_hash 确定性')
|
||
caps = [{'capability_key': 'b', 'version_no': 1}, {'capability_key': 'a', 'version_no': 2}]
|
||
h1 = cn.registry_hash(caps)
|
||
h2 = cn.registry_hash(list(reversed(caps)))
|
||
check('同能力集(顺序无关)registry_hash 全等', h1 == h2 and bool(h1), '%s vs %s' % (h1, h2))
|
||
caps2 = caps + [{'capability_key': 'c', 'version_no': 1}]
|
||
check('新增能力 registry_hash 变化', cn.registry_hash(caps2) != h1)
|
||
|
||
|
||
def main():
|
||
print('=' * 64)
|
||
print('pbl_compiler M3a 自检(QC #1/#2/#3/#4 整改验证)')
|
||
print('=' * 64)
|
||
for fn in (group1_canonical_determinism, group2_gd_top_keys,
|
||
group3_volatile_excluded, group4_register_runs,
|
||
group5_three_place_sync, group6_no_hardcoded_db,
|
||
group7_float_and_sort, group8_registry_hash):
|
||
try:
|
||
fn()
|
||
except Exception as exc: # noqa: BLE001
|
||
import traceback
|
||
FAIL.append('%s(异常)' % fn.__name__)
|
||
print(' [ERROR] %s: %s' % (fn.__name__, exc))
|
||
traceback.print_exc()
|
||
print('-' * 64)
|
||
print('PASS=%d FAIL=%d' % (len(PASS), len(FAIL)))
|
||
if FAIL:
|
||
print('FAILED: %s' % FAIL)
|
||
return 1
|
||
print('ALL GREEN')
|
||
return 0
|
||
|
||
|
||
# ---- pytest 兼容 ----
|
||
def test_m3a_selfcheck():
|
||
assert main() == 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|