pbl_compiler/pbl_compiler/script_mapping.py
2026-09-18 19:27:22 +08:00

1280 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
pbl_compiler.script_mapping —— M3b 第12章event → condition → response (ECR) 到
script_engine `script_type=1` 规则 JSON 的确定性映射。
设计铁律(与 M3a canonical.py 一致):
1. **确定性**:同一蓝图版本快照 → 逐字节相同的规则 JSON。
- `json.dumps(..., sort_keys=True, separators=(',', ':'), ensure_ascii=False)`(零空白)
- 剥离易变字段created_at / uuid / timestamp ...
- 顶层规则按 (-priority, rule_id) 稳定排序rule_id 缺失时由内容哈希派生
2. **fail-closed**tenant_id 缺失、未知 actionstrict 模式)、操作数缺失 → 记 issue 并阻断导出,
绝不静默产出半成品规则。
3. **纯函数层**:本文件不做任何 DB / IO全部可离线单测scripts/test_m3b_mapping.py
落库与产物导出在 exporter.py。
规则 JSONscript_type=1契约 v1.0
{
"schema_version": "1.0",
"rule_id": "...", # 稳定主键,来源 id 或内容哈希派生
"source_id": "...", # 蓝图子对象原始 id可空
"name": "...",
"description": "...",
"priority": 100, # 大者先执行
"enabled": true,
"event": {"kind": "event", "type": "...", "source": "...", "match": {...}},
"condition": {"kind": "all|any|not|compare|expr|always", ...},
"responses": [{"kind": "action", "action": "...", "target": {...}, "params": {...},
"delay_ms": 0, "repeat": 1}],
"limits": {"cooldown_ms": 0, "max_triggers": 0}, # 0 = 不限
"tags": ["..."],
"origin": {"blueprint_id": "...", "blueprint_version": "...",
"sub_object_id": "...", "source_index": 0}
}
"""
import hashlib
import json
import re
__all__ = [
'RULE_SCHEMA_VERSION',
'CANONICAL_OPS',
'OP_ALIASES',
'VOLATILE_KEYS',
'CompileError',
'Issue',
'canonical_dumps',
'sha256_text',
'strip_volatile',
'normalize_operand',
'normalize_condition',
'normalize_event',
'normalize_responses',
'normalize_ecr',
'iter_ecr_sources',
'build_rules',
'validate_rule',
'validate_rules',
'sort_rules',
'canonical_rules_json',
'rules_hash',
'build_rules_manifest',
'diff_rules',
]
RULE_SCHEMA_VERSION = '1.0'
# ---------------------------------------------------------------------------
# 错误与 issue
# ---------------------------------------------------------------------------
class CompileError(Exception):
"""编译期硬错误fail-closed。code 用于前端/日志定位。"""
def __init__(self, code, message, detail=None):
super().__init__('[%s] %s' % (code, message))
self.code = code
self.message = message
self.detail = detail or {}
def Issue(severity, code, message, rule_id='', path=''):
"""统一 issue 结构dict便于 JSON 序列化。severity: error|warning|info"""
return {
'severity': severity,
'code': code,
'message': message,
'rule_id': rule_id or '',
'path': path or '',
}
# ---------------------------------------------------------------------------
# 确定性序列化 / 哈希 / 易变字段剥离
# ---------------------------------------------------------------------------
VOLATILE_KEYS = frozenset([
'created_at', 'updated_at', 'create_time', 'update_time', 'created_by', 'updated_by',
'creator', 'modifier', 'timestamp', 'ts', 'uuid', '_id', 'trace_id', 'request_id',
'generated_at', 'expire_at', 'deleted_at', 'revision', 'etag',
])
def canonical_dumps(obj):
"""确定性 JSONsort_keys + 零空白 + 非 ASCII 原样。优先复用 M3a canonical 实现。"""
try: # pragma: no cover - 依赖同包 canonical.pyM3a 产物)
from . import canonical as _c
for fn in ('canonical_dumps', 'dumps_canonical', 'canonical_json'):
f = getattr(_c, fn, None)
if callable(f):
return f(obj)
except Exception:
pass
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
def sha256_text(text):
if isinstance(text, str):
text = text.encode('utf-8')
return hashlib.sha256(text).hexdigest()
def strip_volatile(obj, depth=0):
"""递归剥离易变字段list 保序顺序是语义的一部分dict 只删键不改值语义。"""
if depth > 32:
return obj
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
ks = str(k)
if ks in VOLATILE_KEYS or ks.startswith('_volatile'):
continue
out[ks] = strip_volatile(v, depth + 1)
return out
if isinstance(obj, (list, tuple)):
return [strip_volatile(v, depth + 1) for v in obj]
return obj
# ---------------------------------------------------------------------------
# 标量工具
# ---------------------------------------------------------------------------
_SNAKE_RE_1 = re.compile(r'(.)([A-Z][a-z]+)')
_SNAKE_RE_2 = re.compile(r'([a-z0-9])([A-Z])')
_NON_WORD_RE = re.compile(r'[^0-9a-zA-Z\u4e00-\u9fa5]+')
def to_snake(name):
"""`onEnter Scene` / `OnEnterScene` → `on_enter_scene`(确定性归一)。"""
if name is None:
return ''
s = str(name).strip()
if not s:
return ''
s = _SNAKE_RE_1.sub(r'\1_\2', s)
s = _SNAKE_RE_2.sub(r'\1_\2', s)
s = _NON_WORD_RE.sub('_', s).strip('_')
s = re.sub(r'_+', '_', s)
return s.lower()
def first_str(d, keys, default=''):
for k in keys:
if isinstance(d, dict) and d.get(k) not in (None, ''):
return str(d.get(k)).strip()
return default
def to_int(v, default=0):
try:
if v is None or v == '':
return int(default)
if isinstance(v, bool):
return int(v)
return int(float(v))
except (TypeError, ValueError):
return int(default)
def to_bool(v, default=True):
if v is None:
return bool(default)
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
s = str(v).strip().lower()
if s in ('1', 'true', 'yes', 'y', 'on', 'enabled', 'active', ''):
return True
if s in ('0', 'false', 'no', 'n', 'off', 'disabled', 'inactive', ''):
return False
return bool(default)
def to_number_or_raw(v):
"""常量值归一:数字字符串转数字(保证 1 与 '1' 编译结果一致),其余原样。"""
if isinstance(v, bool) or v is None:
return v
if isinstance(v, (int, float)):
return v
if isinstance(v, str):
s = v.strip()
if re.fullmatch(r'-?\d+', s):
return int(s)
if re.fullmatch(r'-?\d+\.\d+', s):
return float(s)
if s.lower() in ('true', 'false'):
return s.lower() == 'true'
if s.lower() in ('null', 'none'):
return None
return v
return v
# ---------------------------------------------------------------------------
# 操作符白名单script_engine script_type=1 求值器契约)
# ---------------------------------------------------------------------------
OP_ALIASES = {
'==': 'eq', '=': 'eq', 'eq': 'eq', 'equal': 'eq', 'equals': 'eq', 'is': 'eq', '相同': 'eq',
'!=': 'ne', '<>': 'ne', 'ne': 'ne', 'noteq': 'ne', 'not_equal': 'ne', 'is_not': 'ne',
'>': 'gt', 'gt': 'gt', 'greater_than': 'gt', '大于': 'gt',
'>=': 'gte', '=>': 'gte', 'gte': 'gte', 'ge': 'gte', 'greater_or_equal': 'gte', '大于等于': 'gte',
'<': 'lt', 'lt': 'lt', 'less_than': 'lt', '小于': 'lt',
'<=': 'lte', '=<': 'lte', 'lte': 'lte', 'le': 'lte', 'less_or_equal': 'lte', '小于等于': 'lte',
'in': 'in', 'contains': 'contains', 'has': 'contains', '包含': 'contains',
'not_in': 'not_in', 'nin': 'not_in', 'notcontains': 'not_contains',
'not_contains': 'not_contains',
'startswith': 'startswith', 'endswith': 'endswith',
'matches': 'matches', 'regex': 'matches', 'regexp': 'matches',
'is_true': 'is_true', 'istrue': 'is_true', 'true': 'is_true',
'is_false': 'is_false', 'isfalse': 'is_false', 'false': 'is_false',
'exists': 'exists', 'not_exists': 'not_exists', 'notexists': 'not_exists',
'between': 'between',
}
# 一元 / 二元 / 三元操作符
OP_ARITY = {
'eq': 2, 'ne': 2, 'gt': 2, 'gte': 2, 'lt': 2, 'lte': 2,
'in': 2, 'not_in': 2, 'contains': 2, 'not_contains': 2,
'startswith': 2, 'endswith': 2, 'matches': 2, 'between': 3,
'is_true': 1, 'is_false': 1, 'exists': 1, 'not_exists': 1,
}
CANONICAL_OPS = tuple(sorted(OP_ARITY.keys()))
# 符号操作符(长者优先,直接子串扫描)
_SYMBOL_OPS = sorted([a for a in OP_ALIASES if not a[0].isalnum()], key=len, reverse=True)
# 字母操作符(必须词边界匹配,避免 `remaining` 里的 `in` 误命中)
_WORD_OPS = sorted([a for a in OP_ALIASES if a[0].isalnum()], key=len, reverse=True)
_WORD_OP_RE = re.compile(
r'(?<![0-9A-Za-z_])(' + '|'.join(re.escape(o) for o in _WORD_OPS) + r')(?![0-9A-Za-z_])')
# ---------------------------------------------------------------------------
# 操作数operand归一
# ---------------------------------------------------------------------------
# 剥离前缀(名字本身不含命名空间)
_VAR_STRIP_PREFIXES = ('var:', 'variable:', '$', '@')
# 命名空间前缀(保留完整路径,运行时按路径求值)
_VAR_NS_PREFIXES = ('vars.', 'state.', 'g.', 'global.', 'local.', 'self.')
def normalize_operand(value, issues, path, rule_id=''):
"""把任意写法的操作数归一为 {kind, ...}。
kind:
var {kind:'var', name}
entity_attr {kind:'entity_attr', entity, attr}
event_field {kind:'event_field', field}
const {kind:'const', value}
list {kind:'list', items:[operand...]}
"""
if isinstance(value, dict):
raw_kind = str(value.get('kind') or value.get('type') or '').strip().lower()
if raw_kind in ('var', 'variable', 'varref'):
name = first_str(value, ('name', 'var', 'field', 'key', 'path'))
if not name:
issues.append(Issue('error', 'E_COND_OPERAND', '变量操作数缺少 name', rule_id, path))
return {'kind': 'var', 'name': name}
if raw_kind in ('entity_attr', 'attr', 'attribute', 'property', 'prop'):
return {
'kind': 'entity_attr',
'entity': first_str(value, ('entity', 'entity_id', 'target', 'object')),
'attr': first_str(value, ('attr', 'name', 'property', 'field')),
}
if raw_kind in ('event_field', 'event', 'payload', 'trigger_field'):
return {'kind': 'event_field', 'field': first_str(value, ('field', 'name', 'key'))}
if raw_kind in ('const', 'constant', 'literal', 'value'):
return {'kind': 'const', 'value': to_number_or_raw(value.get('value', value.get('v')))}
if raw_kind in ('list', 'array'):
items = value.get('items') or value.get('values') or []
if not isinstance(items, (list, tuple)):
items = [items]
return {'kind': 'list',
'items': [normalize_operand(i, issues, path + '.items', rule_id) for i in items]}
# 无 kind 的裸 dict按常见键推断
if 'field' in value or 'var' in value or 'name' in value:
name = first_str(value, ('field', 'var', 'name', 'key', 'path'))
if value.get('entity') or value.get('entity_id'):
return {'kind': 'entity_attr',
'entity': first_str(value, ('entity', 'entity_id')),
'attr': name}
return {'kind': 'var', 'name': name}
if 'value' in value:
return {'kind': 'const', 'value': to_number_or_raw(value.get('value'))}
issues.append(Issue('warning', 'W_COND_OPERAND_SHAPE',
'无法识别的操作数结构,按常量对象处理', rule_id, path))
return {'kind': 'const', 'value': strip_volatile(value)}
if isinstance(value, (list, tuple)):
return {'kind': 'list',
'items': [normalize_operand(i, issues, path + '.items', rule_id) for i in value]}
if isinstance(value, str):
s = value.strip()
for p in _VAR_STRIP_PREFIXES:
if s.startswith(p) and len(s) > len(p):
return {'kind': 'var', 'name': s[len(p):].strip().strip('{}')}
if s.startswith('{{') and s.endswith('}}'):
return {'kind': 'var', 'name': s[2:-2].strip()}
for p in _VAR_NS_PREFIXES:
if s.startswith(p) and len(s) > len(p):
return {'kind': 'var', 'name': s}
return {'kind': 'const', 'value': to_number_or_raw(value)}
return {'kind': 'const', 'value': to_number_or_raw(value)}
# ---------------------------------------------------------------------------
# condition 归一(递归布尔树)
# ---------------------------------------------------------------------------
_ALWAYS = {'kind': 'always'}
_IDENT_RE = re.compile(r'^[A-Za-z_\u4e00-\u9fa5][0-9A-Za-z_\.\u4e00-\u9fa5\[\]\'\"]*$')
def _operand_from_expr_token(token, issues, path, rule_id):
"""字符串表达式里的操作数:裸标识符 → var其余走通用归一。"""
t = str(token).strip().strip('()')
if _IDENT_RE.match(t) and not re.fullmatch(r'-?\d+(\.\d+)?', t):
return {'kind': 'var', 'name': t}
return normalize_operand(t, issues, path, rule_id)
def _parse_string_expr(text, issues, path, rule_id):
"""尽力解析 `score >= 60` / `state.done == true` 形式的字符串条件。"""
s = str(text).strip()
if not s:
return dict(_ALWAYS)
low = s.lower()
if low in ('true', 'always', '1', ''):
return dict(_ALWAYS)
if low in ('false', 'never', '0', ''):
return {'kind': 'not', 'item': dict(_ALWAYS)}
candidates = []
for op in _SYMBOL_OPS:
idx = s.find(op)
while idx > 0:
candidates.append((idx, op))
idx = s.find(op, idx + 1)
for m in _WORD_OP_RE.finditer(s):
if m.start() > 0:
candidates.append((m.start(), m.group(1)))
# 取最靠左的操作符;同位置取最长(`>=` 优于 `>`
candidates.sort(key=lambda c: (c[0], -len(c[1])))
for idx, op in candidates:
left = s[:idx].strip()
right = s[idx + len(op):].strip()
if not left:
continue
canon = OP_ALIASES.get(op.lower()) or OP_ALIASES.get(op)
if not canon:
continue
arity = OP_ARITY.get(canon, 2)
if arity == 1:
if right:
continue
return {'kind': 'compare', 'op': canon,
'left': _operand_from_expr_token(left, issues, path + '.left', rule_id)}
if not right:
continue
return {
'kind': 'compare',
'op': canon,
'left': _operand_from_expr_token(left, issues, path + '.left', rule_id),
'right': _operand_from_expr_token(right, issues, path + '.right', rule_id),
}
issues.append(Issue('warning', 'W_COND_EXPR_UNPARSED',
'字符串条件未能解析为结构化比较,按 expr 原样保留: %s' % s, rule_id, path))
return {'kind': 'expr', 'text': s}
def normalize_condition(raw, issues, path='condition', rule_id=''):
"""归一条件树。返回 None 表示「无条件」(调用方替换为 always"""
if raw is None or raw == '' or raw == [] or raw == {}:
return None
if isinstance(raw, str):
return _parse_string_expr(raw, issues, path, rule_id)
if isinstance(raw, (list, tuple)):
items = [normalize_condition(x, issues, '%s[%d]' % (path, i), rule_id)
for i, x in enumerate(raw)]
items = [i for i in items if i is not None]
if not items:
return None
if len(items) == 1:
return items[0]
return {'kind': 'all', 'items': items}
if not isinstance(raw, dict):
issues.append(Issue('warning', 'W_COND_SHAPE', '条件类型不支持,按常量真处理', rule_id, path))
return dict(_ALWAYS)
kind = str(raw.get('kind') or raw.get('type') or '').strip().lower()
# —— GDM3a gd_builder.build_events形态all_of / any_of / none_of 字符串表达式数组 ——
for key, comb in (('all_of', 'all'), ('allof', 'all'), ('and', 'all'),
('any_of', 'any'), ('anyof', 'any'), ('or', 'any'),
('none_of', 'not'), ('noneof', 'not'), ('nor', 'not')):
if key in raw and isinstance(raw.get(key), (list, tuple, str, dict)):
val = raw[key]
items_raw = val if isinstance(val, (list, tuple)) else [val]
items = [normalize_condition(x, issues, '%s.%s[%d]' % (path, key, i), rule_id)
for i, x in enumerate(items_raw)]
items = [i for i in items if i is not None]
if not items:
return dict(_ALWAYS)
node = items[0] if len(items) == 1 else {'kind': comb if comb != 'not' else 'all',
'items': items}
if comb == 'not':
node = {'kind': 'not',
'item': (items[0] if len(items) == 1
else {'kind': 'any', 'items': items})}
# 与其它键并存时(如 all_of + min_count合并为 all
rest = {k: v for k, v in raw.items()
if str(k).strip().lower().replace('_', '') != key.replace('_', '')}
if rest:
extra = normalize_condition(rest, issues, path + '.rest', rule_id)
if extra is not None and extra != dict(_ALWAYS):
node = {'kind': 'all', 'items': [node, extra]}
return node
# —— 计数型条件GD evidence: {'min_count': N})——
for key, op in (('min_count', 'gte'), ('max_count', 'lte'), ('count', 'eq'),
('min', 'gte'), ('max', 'lte')):
if key in raw and raw.get(key) not in (None, ''):
subject = raw.get('subject') or raw.get('field') or raw.get('var') or key
node = {'kind': 'compare', 'op': op,
'left': normalize_operand(subject, issues, path + '.left', rule_id),
'right': normalize_operand(raw.get(key), issues, path + '.right', rule_id)}
rest = {k: v for k, v in raw.items()
if str(k).strip().lower() not in (key, 'subject', 'field', 'var')}
if rest:
extra = normalize_condition(rest, issues, path + '.rest', rule_id)
if extra is not None and extra != dict(_ALWAYS):
node = {'kind': 'all', 'items': [node, extra]}
return node
# 布尔组合子
if kind in ('all', 'and', 'every'):
children = raw.get('items') or raw.get('conditions') or raw.get('all') or raw.get('children') or []
items = [normalize_condition(c, issues, '%s.all[%d]' % (path, i), rule_id)
for i, c in enumerate(children if isinstance(children, (list, tuple)) else [children])]
items = [i for i in items if i is not None]
if not items:
return dict(_ALWAYS)
return {'kind': 'all', 'items': items} if len(items) > 1 else items[0]
if kind in ('any', 'or', 'some'):
children = raw.get('items') or raw.get('conditions') or raw.get('any') or raw.get('children') or []
items = [normalize_condition(c, issues, '%s.any[%d]' % (path, i), rule_id)
for i, c in enumerate(children if isinstance(children, (list, tuple)) else [children])]
items = [i for i in items if i is not None]
if not items:
return dict(_ALWAYS)
return {'kind': 'any', 'items': items} if len(items) > 1 else items[0]
if kind in ('not', 'negate'):
child = raw.get('item') or raw.get('condition') or raw.get('not')
if isinstance(child, (list, tuple)) and child:
child = child[0]
item = normalize_condition(child, issues, path + '.not', rule_id)
if item is None:
return {'kind': 'not', 'item': dict(_ALWAYS)}
return {'kind': 'not', 'item': item}
if kind in ('always', 'true', 'unconditional'):
return dict(_ALWAYS)
if kind == 'expr' or (raw.get('expr') and not raw.get('op') and not raw.get('operator')):
text = str(raw.get('text') or raw.get('expr') or '').strip()
if not text:
return dict(_ALWAYS)
issues.append(Issue('info', 'I_COND_EXPR', '保留表达式条件: %s' % text, rule_id, path))
return {'kind': 'expr', 'text': text}
# 比较节点
op_raw = raw.get('op') or raw.get('operator') or raw.get('cmp') or raw.get('compare') or raw.get('rel')
if op_raw is not None:
canon = OP_ALIASES.get(str(op_raw).strip().lower())
if not canon:
issues.append(Issue('error', 'E_COND_OP_UNKNOWN',
'未知条件操作符: %r(白名单: %s' % (op_raw, ', '.join(CANONICAL_OPS)),
rule_id, path))
canon = 'eq'
arity = OP_ARITY.get(canon, 2)
left_raw = raw.get('left')
if left_raw is None:
left_raw = raw.get('field') if raw.get('field') is not None else raw.get('var')
if left_raw is None:
left_raw = raw.get('name')
right_raw = raw.get('right')
if right_raw is None:
right_raw = raw.get('value') if raw.get('value') is not None else raw.get('v')
if right_raw is None:
right_raw = raw.get('to')
if left_raw is None:
issues.append(Issue('error', 'E_COND_OPERAND', '比较条件缺少左操作数', rule_id, path))
left_raw = ''
node = {'kind': 'compare', 'op': canon,
'left': normalize_operand(left_raw, issues, path + '.left', rule_id)}
if arity >= 2:
if canon in ('in', 'not_in') and isinstance(right_raw, str):
right_raw = [x.strip() for x in right_raw.split(',') if x.strip()]
if canon == 'between':
pair = right_raw if isinstance(right_raw, (list, tuple)) else \
[raw.get('from'), raw.get('to')]
pair = [p for p in pair if p is not None]
if len(pair) != 2:
issues.append(Issue('error', 'E_COND_BETWEEN',
'between 需要 [from, to] 两个边界', rule_id, path))
pair = (pair + [None, None])[:2]
node['right'] = {'kind': 'list',
'items': [normalize_operand(p, issues, path + '.right', rule_id)
for p in pair]}
else:
if right_raw is None:
issues.append(Issue('error', 'E_COND_OPERAND',
'操作符 %s 缺少右操作数' % canon, rule_id, path))
right_raw = ''
node['right'] = normalize_operand(right_raw, issues, path + '.right', rule_id)
return node
# 隐式:{field, value} / {var, op, value}
if raw.get('field') or raw.get('var') or raw.get('name'):
return normalize_condition(
{'op': raw.get('op') or raw.get('operator') or 'eq',
'left': raw.get('field') or raw.get('var') or raw.get('name'),
'right': raw.get('value', raw.get('v'))},
issues, path, rule_id)
issues.append(Issue('warning', 'W_COND_SHAPE',
'无法识别的条件结构,按 expr 保留', rule_id, path))
return {'kind': 'expr', 'text': canonical_dumps(strip_volatile(raw))}
# ---------------------------------------------------------------------------
# event 归一
# ---------------------------------------------------------------------------
EVENT_TYPE_ALIASES = {
'onenter': 'enter', 'on_enter': 'enter', 'sceneenter': 'enter', 'scene_enter': 'enter',
'onexit': 'exit', 'on_exit': 'exit', 'sceneexit': 'exit', 'scene_exit': 'exit',
'onclick': 'click', 'on_click': 'click', 'click': 'click', 'tap': 'click',
'ontimer': 'timer', 'on_timer': 'timer', 'tick': 'timer', 'interval': 'timer',
'oncollision': 'collision', 'on_collision': 'collision', 'collide': 'collision',
'onmessage': 'message', 'on_message': 'message', 'msg': 'message',
'onchange': 'change', 'on_change': 'change', 'valuechange': 'change', 'value_change': 'change',
'onstart': 'start', 'on_start': 'start', 'gamestart': 'start', 'game_start': 'start',
'onend': 'end', 'on_end': 'end', 'gameend': 'end', 'game_end': 'end', 'finish': 'end',
'ontrigger': 'trigger', 'on_trigger': 'trigger', 'custom': 'custom',
'onsubmit': 'submit', 'on_submit': 'submit', 'submit': 'submit',
'onanswer': 'answer', 'on_answer': 'answer', 'answer': 'answer',
'onvaluechange': 'change', 'onvarchange': 'change', 'onvariablechange': 'change',
'onkeypress': 'keypress', 'on_key_press': 'keypress', 'keypress': 'keypress',
'oninput': 'input', 'on_input': 'input', 'input': 'input',
'onwin': 'win', 'on_win': 'win', 'win': 'win', 'onlose': 'lose', 'on_lose': 'lose',
'lose': 'lose', 'oncomplete': 'complete', 'on_complete': 'complete',
'complete': 'complete', 'onpick': 'pick', 'on_pick': 'pick', 'pick': 'pick',
'ondrop': 'drop', 'on_drop': 'drop', 'drop': 'drop',
}
def normalize_event(raw, issues, path='event', rule_id='', known_types=None):
"""归一事件触发器。缺失 → type='unknown' + error issuefail-closed 由调用方决定)。"""
if raw is None or raw == '' or raw == {} or raw == []:
issues.append(Issue('error', 'E_EVENT_MISSING', '规则缺少 event 触发器', rule_id, path))
return {'kind': 'event', 'type': 'unknown', 'source': '', 'match': {}}
if isinstance(raw, str):
raw = {'type': raw}
elif isinstance(raw, (list, tuple)):
raw = raw[0] if raw else {}
issues.append(Issue('warning', 'W_EVENT_MULTI',
'一个规则只支持单事件触发,已取第一个', rule_id, path))
if not isinstance(raw, dict):
issues.append(Issue('error', 'E_EVENT_SHAPE', '事件结构不支持: %r' % type(raw).__name__,
rule_id, path))
return {'kind': 'event', 'type': 'unknown', 'source': '', 'match': {}}
etype_raw = first_str(raw, ('type', 'event', 'event_type', 'name', 'trigger', 'on'))
etype = to_snake(etype_raw)
if etype:
compact = etype.replace('_', '')
base = etype[3:] if etype.startswith('on_') else etype
base_compact = base.replace('_', '')
etype = (EVENT_TYPE_ALIASES.get(compact)
or EVENT_TYPE_ALIASES.get(base_compact)
or base or etype)
etype = etype or 'unknown'
if not etype_raw:
issues.append(Issue('error', 'E_EVENT_MISSING', '事件缺少 type', rule_id, path))
elif known_types and etype not in known_types \
and to_snake(etype_raw) not in {to_snake(t) for t in known_types}:
issues.append(Issue('warning', 'W_EVENT_TYPE_UNKNOWN',
'事件类型 %s 未在能力/枚举登记表中' % etype, rule_id, path))
source = first_str(raw, ('source', 'source_id', 'from', 'emitter', 'target', 'object'))
# GD 形态 source = 'pbl_mission:12' → 拆 kind / id保留原串
source_kind = ''
source_ref = source
if source and ':' in source:
source_kind, _, source_ref = source.partition(':')
source_kind = to_snake(source_kind)
match_raw = raw.get('match') or raw.get('filter') or raw.get('args') or raw.get('payload') or {}
match = {}
if isinstance(match_raw, dict):
for k, v in strip_volatile(match_raw).items():
ks = to_snake(k)
if not ks:
continue
if isinstance(v, (dict, list, tuple)):
match[ks] = strip_volatile(v)
else:
match[ks] = to_number_or_raw(v)
elif match_raw not in (None, '', [], {}):
issues.append(Issue('warning', 'W_EVENT_MATCH_SHAPE',
'event.match 必须是对象,已忽略', rule_id, path))
# timer 事件的周期参数提升到 match保证运行时可调度
for key in ('interval_ms', 'interval', 'delay_ms', 'delay', 'period_ms', 'period'):
if raw.get(key) not in (None, ''):
match.setdefault('interval_ms', to_int(raw.get(key), 0))
ev = {'kind': 'event', 'type': etype, 'source': source, 'match': match}
if source_kind:
ev['source_kind'] = source_kind
ev['source_ref'] = source_ref
return ev
# ---------------------------------------------------------------------------
# response 归一
# ---------------------------------------------------------------------------
RESPONSE_ALIASES = {
'then': 'responses', 'response': 'responses', 'actions': 'responses',
'action': 'responses', 'do': 'responses', 'effects': 'responses', 'effect': 'responses',
}
def _normalize_one_response(raw, index, issues, path, rule_id, known_actions, strict):
if raw is None or raw == '':
return None
if isinstance(raw, str):
raw = {'action': raw}
if not isinstance(raw, dict):
issues.append(Issue('error', 'E_RESP_SHAPE',
'响应必须是对象或字符串,得到 %s' % type(raw).__name__, rule_id, path))
return None
action_raw = first_str(raw, ('action', 'type', 'kind', 'name', 'op', 'command', 'do'))
action = to_snake(action_raw)
if not action:
issues.append(Issue('error', 'E_RESP_ACTION_MISSING', '响应缺少 action', rule_id, path))
return None
if known_actions is not None and action not in known_actions:
sev = 'error' if strict else 'warning'
issues.append(Issue(sev, 'E_CAP_UNKNOWN' if strict else 'W_CAP_UNKNOWN',
'响应 action `%s` 未在 pbl_capability_registry 登记'
% action, rule_id, path))
if strict:
return None
target_raw = raw.get('target')
if target_raw is None:
target_raw = raw.get('to') if raw.get('to') is not None else raw.get('object')
if isinstance(target_raw, dict):
target = {'kind': str(target_raw.get('kind') or target_raw.get('type') or 'entity'),
'id': first_str(target_raw, ('id', 'entity_id', 'name', 'value'))}
elif isinstance(target_raw, (list, tuple)):
target = {'kind': 'list', 'id': '',
'ids': sorted({str(x) for x in target_raw if str(x)})}
elif target_raw in (None, ''):
target = {'kind': 'none', 'id': ''}
else:
s = str(target_raw).strip()
if s.lower() in ('self', 'this', 'trigger', 'source'):
target = {'kind': 'self', 'id': ''}
elif s.lower() in ('all', 'everyone', '*'):
target = {'kind': 'all', 'id': ''}
else:
target = {'kind': 'entity', 'id': s}
params_raw = raw.get('params')
if params_raw is None:
params_raw = raw.get('args') or raw.get('payload') or raw.get('data')
if params_raw is None:
# GD 形态response 项把业务字段平铺({'op':'emit','event':'x','capability':'pbl.y'}
_RESERVED = {'action', 'type', 'kind', 'name', 'op', 'command', 'do',
'target', 'to', 'object', 'params', 'args', 'payload', 'data',
'delay_ms', 'delay', 'repeat', 'times', 'condition', 'when', 'if'}
flat = {k: v for k, v in raw.items() if str(k).strip().lower() not in _RESERVED}
params_raw = strip_volatile(flat)
if not isinstance(params_raw, dict):
if params_raw in (None, '', [], {}):
params_raw = {}
else:
params_raw = {'value': to_number_or_raw(params_raw)}
params = {}
for k, v in strip_volatile(params_raw).items():
ks = to_snake(k)
if not ks:
continue
params[ks] = _normalize_param_value(v)
# 常见同义参数键归一
for a, b in (('val', 'value'), ('amount', 'value'), ('delta', 'value'),
('text', 'message'), ('msg', 'message'), ('scene', 'scene_id'),
('next_scene', 'scene_id'), ('to_scene', 'scene_id')):
if a in params and b not in params:
params[b] = params.pop(a)
out = {
'kind': 'action',
'action': action,
'target': target,
'params': params,
'delay_ms': max(0, to_int(raw.get('delay_ms', raw.get('delay', 0)), 0)),
'repeat': max(0, to_int(raw.get('repeat', raw.get('times', 1)), 1)),
}
cap = first_str(raw, ('capability', 'capability_key', 'cap')) or \
first_str(params, ('capability', 'capability_key'))
if cap:
out['capability'] = cap
params.pop('capability', None)
params.pop('capability_key', None)
ev = first_str(raw, ('event', 'emit', 'emit_event'))
if ev and action in ('emit', 'emit_event', 'fire'):
params.setdefault('event', to_snake(ev) or ev)
if raw.get('condition') not in (None, '', [], {}):
sub = normalize_condition(raw.get('condition'), issues, path + '.condition', rule_id)
if sub is not None:
out['condition'] = sub
return out
def _normalize_param_value(v, depth=0):
if depth > 16:
return None
if isinstance(v, dict):
return {to_snake(k) or str(k): _normalize_param_value(x, depth + 1)
for k, x in strip_volatile(v).items()}
if isinstance(v, (list, tuple)):
return [_normalize_param_value(x, depth + 1) for x in v]
return to_number_or_raw(v)
def normalize_responses(raw, issues, path='responses', rule_id='',
known_actions=None, strict=True):
if raw is None or raw == '' or raw == {} or raw == []:
issues.append(Issue('error', 'E_RESP_MISSING', '规则缺少 response至少一个动作',
rule_id, path))
return []
if isinstance(raw, dict):
# {"responses": [...]} 嵌套 或 单个动作对象
inner = None
for k, v in raw.items():
if str(k).strip().lower() in RESPONSE_ALIASES and isinstance(v, (list, tuple, dict)):
inner = v
break
raw = inner if inner is not None else [raw]
if not isinstance(raw, (list, tuple)):
raw = [raw]
out = []
for i, item in enumerate(raw):
r = _normalize_one_response(item, i, issues, '%s[%d]' % (path, i),
rule_id, known_actions, strict)
if r is not None:
out.append(r)
if not out:
issues.append(Issue('error', 'E_RESP_EMPTY',
'规则响应归一后为空strict 模式下未登记 action 会被剔除)',
rule_id, path))
return out
# ---------------------------------------------------------------------------
# ECR 源枚举(兼容蓝图快照的多种形态)
# ---------------------------------------------------------------------------
_ECR_LIST_KEYS = ('ecr', 'ecrs', 'rules', 'rule', 'event_rules', 'logic', 'behaviors',
'triggers', 'event_conditions', 'scripts')
_EVENT_LIST_KEYS = ('events', 'event', 'scene_events')
def iter_ecr_sources(snapshot):
"""从蓝图版本快照里确定性地枚举 ECR 原始记录。
支持形态:
A. snapshot['rules' | 'ecr' | 'logic' | ...] = [ {event, condition, responses}, ... ]
B. snapshot['events'] = [ {id, type, conditions, responses}, ... ] (事件内嵌条件/响应)
C. snapshot['scenes'] = [ {events: [...] } ] (场景内嵌事件)
D. snapshot['children' | 'sub_objects' | 'objects'] = [ {kind:'rule'|'event', ...} ]
返回 [(source_path, raw_dict), ...],按 source_path 排序保证确定性。
"""
found = []
if not isinstance(snapshot, dict):
return found
def _walk(node, prefix, depth=0):
if depth > 8 or not isinstance(node, dict):
return
for key in _ECR_LIST_KEYS:
val = node.get(key)
if isinstance(val, dict):
val = [val]
if isinstance(val, (list, tuple)):
for i, item in enumerate(val):
if isinstance(item, dict) and _looks_like_ecr(item):
found.append(('%s.%s[%d]' % (prefix, key, i), item))
for key in _EVENT_LIST_KEYS:
val = node.get(key)
if isinstance(val, dict):
val = [val]
if isinstance(val, (list, tuple)):
for i, item in enumerate(val):
if isinstance(item, dict) and _looks_like_ecr(item):
found.append(('%s.%s[%d]' % (prefix, key, i), item))
for key in ('scenes', 'scene', 'children', 'sub_objects', 'objects', 'nodes', 'items'):
val = node.get(key)
if isinstance(val, dict):
val = [val]
if isinstance(val, (list, tuple)):
for i, item in enumerate(val):
if isinstance(item, dict):
_walk(item, '%s.%s[%d]' % (prefix, key, i), depth + 1)
_walk(snapshot, 'snapshot')
# 去重(同一对象可能同时出现在 rules 与 events 下):按内容哈希保留首个路径
seen = {}
uniq = []
for path, raw in sorted(found, key=lambda x: x[0]):
h = sha256_text(canonical_dumps(strip_volatile(raw)))
if h in seen:
continue
seen[h] = path
uniq.append((path, raw))
return uniq
_TRIGGER_KEYS = {'event', 'trigger', 'on', 'type', 'event_type'}
_EFFECT_KEYS = {'responses', 'response', 'actions', 'action', 'then', 'do', 'effects', 'effect'}
_COND_KEYS = {'condition', 'conditions', 'when', 'if'}
# 容器 / 实体标记键:命中且无触发器时不视为 ECR避免把 scene/entity 误当规则)
_CONTAINER_KEYS = {'scenes', 'scene', 'children', 'sub_objects', 'objects', 'nodes',
'events', 'rules', 'items', 'entities', 'components', 'assets',
'position', 'transform', 'mesh', 'variables', 'ui'}
_ECR_KINDS = {'rule', 'ecr', 'event_rule', 'logic', 'behavior', 'trigger', 'event'}
def _looks_like_ecr(d):
keys = {str(k).strip().lower() for k in d.keys()}
has_trigger = bool(keys & _TRIGGER_KEYS)
has_effect = bool(keys & _EFFECT_KEYS)
has_cond = bool(keys & _COND_KEYS)
kind = str(d.get('kind') or d.get('object_type') or d.get('sub_type') or '').lower()
if kind in _ECR_KINDS:
return True
score = sum([has_trigger, has_effect, has_cond])
if score == 0:
return False
if (keys & _CONTAINER_KEYS) and not has_trigger:
return False
# 只有响应没有触发器:需带 id/name 标识才当规则(畸形规则要报错,不能静默丢)
if not has_trigger and not has_cond:
return bool(keys & {'id', 'name', 'rule_id', 'key', 'code'})
return True
# ---------------------------------------------------------------------------
# 单条 ECR → 规则
# ---------------------------------------------------------------------------
def _derive_rule_id(rule_core):
return 'rule_' + sha256_text(canonical_dumps(rule_core))[:16]
def normalize_ecr(raw, index, ctx=None, source_path=''):
"""把一条原始 ECR 记录归一为 script_type=1 规则 JSON。返回 (rule, issues)。"""
ctx = ctx or {}
issues = []
raw = strip_volatile(raw) if isinstance(raw, dict) else {}
source_id = first_str(raw, ('id', 'rule_id', 'event_key', 'key', 'code',
'object_id', 'sub_object_id'))
name = first_str(raw, ('name', 'title', 'label', 'rule_name')) or \
'rule_%03d' % (index + 1)
description = first_str(raw, ('description', 'desc', 'remark', 'memo'))
priority = to_int(raw.get('priority', raw.get('order', raw.get('weight', 100))), 100)
enabled = to_bool(raw.get('enabled', raw.get('active', raw.get('is_active', True))), True)
rule_id_hint = source_id
tmp_id = rule_id_hint or ('pending_%d' % index)
event = normalize_event(
raw.get('event') if raw.get('event') is not None else
(raw.get('trigger') if raw.get('trigger') is not None else raw.get('on')),
issues, 'event', tmp_id, ctx.get('event_types'))
# 形态 B事件对象自身即触发器{id,type,conditions,responses}
if event.get('type') == 'unknown' and (raw.get('type') or raw.get('event_type')):
event = normalize_event({'type': raw.get('type') or raw.get('event_type'),
'source': raw.get('source') or raw.get('source_id'),
'match': raw.get('match') or raw.get('args') or {}},
issues, 'event', tmp_id, ctx.get('event_types'))
issues[:] = [i for i in issues if i['code'] != 'E_EVENT_MISSING']
cond_raw = raw.get('condition')
if cond_raw is None:
cond_raw = raw.get('conditions')
if cond_raw is None:
cond_raw = raw.get('when')
if cond_raw is None:
cond_raw = raw.get('if')
condition = normalize_condition(cond_raw, issues, 'condition', tmp_id)
if condition is None:
condition = dict(_ALWAYS)
resp_raw = raw.get('responses')
for k in ('response', 'actions', 'action', 'then', 'do', 'effects'):
if resp_raw is None:
resp_raw = raw.get(k)
responses = normalize_responses(resp_raw, issues, 'responses', tmp_id,
ctx.get('known_actions'),
bool(ctx.get('strict', True)))
limits = raw.get('limits') if isinstance(raw.get('limits'), dict) else {}
limits_out = {
'cooldown_ms': max(0, to_int(
limits.get('cooldown_ms', raw.get('cooldown_ms', raw.get('cooldown', 0))), 0)),
'max_triggers': max(0, to_int(
limits.get('max_triggers', raw.get('max_triggers', raw.get('max_times', 0))), 0)),
}
tags_raw = raw.get('tags') or raw.get('tag') or raw.get('labels') or []
if isinstance(tags_raw, str):
tags_raw = [t for t in re.split(r'[,;|\s]+', tags_raw) if t]
tags = sorted({to_snake(t) or str(t).strip() for t in tags_raw if str(t).strip()})
origin = {
'blueprint_id': str(ctx.get('blueprint_id') or ''),
'blueprint_version': str(ctx.get('blueprint_version') or ''),
'sub_object_id': source_id,
'source_index': int(index),
'source_path': source_path or '',
}
core = {
'schema_version': RULE_SCHEMA_VERSION,
'name': name,
'description': description,
'priority': priority,
'enabled': bool(enabled),
'event': event,
'condition': condition,
'responses': responses,
'limits': limits_out,
'tags': tags,
'origin': origin,
}
rule_id = source_id if source_id else _derive_rule_id(core)
rule = dict(core)
rule['rule_id'] = rule_id
rule['source_id'] = source_id
# 键序无关canonical_dumps sort_keys但保持可读顺序
ordered = {
'schema_version': rule['schema_version'],
'rule_id': rule['rule_id'],
'source_id': rule['source_id'],
'name': rule['name'],
'description': rule['description'],
'priority': rule['priority'],
'enabled': rule['enabled'],
'event': rule['event'],
'condition': rule['condition'],
'responses': rule['responses'],
'limits': rule['limits'],
'tags': rule['tags'],
'origin': rule['origin'],
}
for i in issues:
i['rule_id'] = rule_id
return ordered, issues
# ---------------------------------------------------------------------------
# 批量编译
# ---------------------------------------------------------------------------
def sort_rules(rules):
"""确定性排序priority 降序 → rule_id 升序。"""
return sorted(rules, key=lambda r: (-to_int(r.get('priority'), 0), str(r.get('rule_id'))))
def build_rules(snapshot, ctx=None):
"""蓝图版本快照 → (rules, issues)。
ctx: {tenant_id, blueprint_id, blueprint_version, known_actions(set|None),
event_types(set|None), strict(bool, 默认 True)}
"""
ctx = dict(ctx or {})
ctx.setdefault('strict', True)
issues = []
tenant_id = str(ctx.get('tenant_id') or '').strip()
if not tenant_id:
raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝编译(多租户铁律)')
if not isinstance(snapshot, dict) or not snapshot:
raise CompileError('E_SNAPSHOT_EMPTY', '蓝图版本快照为空,无法映射规则')
sources = iter_ecr_sources(snapshot)
if not sources:
issues.append(Issue('warning', 'W_NO_ECR', '快照中未发现 event→condition→response 记录'))
rules = []
seen_ids = {}
dropped = 0
for idx, (path, raw) in enumerate(sources):
rule, ri = normalize_ecr(raw, idx, ctx, path)
issues.extend(ri)
# fail-closed触发器无效或响应为空的规则不进入产物错误已记 issue编译整体失败
if not rule['responses'] or (rule['event'] or {}).get('type') in (None, '', 'unknown'):
dropped += 1
issues.append(Issue('error', 'E_RULE_DROPPED',
'规则被剔除event 无效或 responses 为空),不产出半成品: %s'
% (rule.get('source_id') or rule.get('name') or path),
rule['rule_id'], path))
continue
rid = rule['rule_id']
if rid in seen_ids:
issues.append(Issue('error', 'E_RULE_ID_DUP',
'rule_id 重复: %s(首次出现 %s,本次 %s'
% (rid, seen_ids[rid], path), rid, path))
rule['rule_id'] = rid + '_' + sha256_text(path)[:6]
rid = rule['rule_id']
seen_ids[rid] = path
rules.append(rule)
rules = sort_rules(rules)
# origin.source_index 保留原始枚举序确保可追溯dropped 计数供上层告警
if dropped:
issues.append(Issue('error', 'E_RULES_DROPPED_TOTAL',
'共剔除 %d 条无效规则' % dropped))
return rules, issues
# ---------------------------------------------------------------------------
# 校验
# ---------------------------------------------------------------------------
_REQUIRED_RULE_KEYS = ('schema_version', 'rule_id', 'name', 'priority', 'enabled',
'event', 'condition', 'responses', 'limits', 'tags', 'origin')
def validate_rule(rule):
"""校验单条已归一规则,返回 issues不修改入参"""
issues = []
if not isinstance(rule, dict):
return [Issue('error', 'E_RULE_SHAPE', '规则必须是对象')]
rid = str(rule.get('rule_id') or '')
for k in _REQUIRED_RULE_KEYS:
if k not in rule:
issues.append(Issue('error', 'E_RULE_KEY_MISSING', '规则缺少字段 %s' % k, rid))
if not rid:
issues.append(Issue('error', 'E_RULE_ID_MISSING', '规则缺少 rule_id'))
if str(rule.get('schema_version') or '') != RULE_SCHEMA_VERSION:
issues.append(Issue('error', 'E_RULE_SCHEMA',
'schema_version 必须为 %s' % RULE_SCHEMA_VERSION, rid))
ev = rule.get('event') or {}
if not isinstance(ev, dict) or not ev.get('type') or ev.get('type') == 'unknown':
issues.append(Issue('error', 'E_EVENT_MISSING', '规则 event.type 无效', rid, 'event'))
resp = rule.get('responses')
if not isinstance(resp, list) or not resp:
issues.append(Issue('error', 'E_RESP_EMPTY', '规则至少需要一个 response', rid, 'responses'))
else:
for i, r in enumerate(resp):
if not isinstance(r, dict) or not r.get('action'):
issues.append(Issue('error', 'E_RESP_ACTION_MISSING',
'response[%d] 缺少 action' % i, rid, 'responses[%d]' % i))
elif not re.fullmatch(r'[a-z][a-z0-9_]{0,63}', str(r.get('action'))):
issues.append(Issue('error', 'E_RESP_ACTION_FORMAT',
'action 必须是 lower_snake≤64字符: %r' % r.get('action'),
rid, 'responses[%d]' % i))
_validate_condition(rule.get('condition'), issues, rid, 'condition')
return issues
def _validate_condition(node, issues, rid, path, depth=0):
if depth > 24:
issues.append(Issue('error', 'E_COND_DEPTH', '条件嵌套过深(>24', rid, path))
return
if node is None:
issues.append(Issue('error', 'E_COND_MISSING', '条件为空', rid, path))
return
if not isinstance(node, dict):
issues.append(Issue('error', 'E_COND_SHAPE', '条件必须是对象', rid, path))
return
kind = node.get('kind')
if kind == 'always':
return
if kind in ('all', 'any'):
items = node.get('items')
if not isinstance(items, list) or not items:
issues.append(Issue('error', 'E_COND_ITEMS', '%s 需要非空 items' % kind, rid, path))
return
for i, it in enumerate(items):
_validate_condition(it, issues, rid, '%s.%s[%d]' % (path, kind, i), depth + 1)
return
if kind == 'not':
_validate_condition(node.get('item'), issues, rid, path + '.not', depth + 1)
return
if kind == 'expr':
if not str(node.get('text') or '').strip():
issues.append(Issue('error', 'E_COND_EXPR_EMPTY', 'expr 条件缺少 text', rid, path))
return
if kind == 'compare':
op = node.get('op')
if op not in OP_ARITY:
issues.append(Issue('error', 'E_COND_OP_UNKNOWN',
'未知操作符 %r' % op, rid, path))
return
arity = OP_ARITY[op]
left = node.get('left')
if not isinstance(left, dict) or not left.get('kind'):
issues.append(Issue('error', 'E_COND_OPERAND', '左操作数无效', rid, path + '.left'))
elif left.get('kind') == 'var' and not left.get('name'):
issues.append(Issue('error', 'E_COND_OPERAND', '变量操作数 name 为空', rid, path + '.left'))
if arity >= 2:
right = node.get('right')
if not isinstance(right, dict) or not right.get('kind'):
issues.append(Issue('error', 'E_COND_OPERAND', '右操作数无效', rid, path + '.right'))
elif op == 'between':
items = right.get('items') or []
if len(items) != 2 or any(i.get('value') is None for i in items
if isinstance(i, dict)):
issues.append(Issue('error', 'E_COND_BETWEEN',
'between 需要两个非空边界', rid, path + '.right'))
return
issues.append(Issue('error', 'E_COND_KIND', '未知条件 kind: %r' % kind, rid, path))
def validate_rules(rules):
issues = []
ids = set()
for r in rules or []:
rid = str((r or {}).get('rule_id') or '')
if rid and rid in ids:
issues.append(Issue('error', 'E_RULE_ID_DUP', 'rule_id 重复: %s' % rid, rid))
ids.add(rid)
issues.extend(validate_rule(r))
return issues
def has_errors(issues):
return any(i.get('severity') == 'error' for i in issues or [])
# ---------------------------------------------------------------------------
# 产物序列化
# ---------------------------------------------------------------------------
def canonical_rules_json(rules):
"""规则数组 → 确定性 JSON 文本script_engine script_type=1 直接可加载)。"""
return canonical_dumps(sort_rules(rules or []))
def rule_content_json(rule):
"""单条规则 → 确定性 JSON 文本(写入 script 表 content 字段)。"""
return canonical_dumps(rule)
def rules_hash(rules):
return sha256_text(canonical_rules_json(rules))
def count_conditions(node):
if not isinstance(node, dict):
return 0
kind = node.get('kind')
if kind in ('all', 'any'):
return 1 + sum(count_conditions(i) for i in (node.get('items') or []))
if kind == 'not':
return 1 + count_conditions(node.get('item'))
return 1
def build_rules_manifest(rules, extra=None):
"""规则清单(供版本比对 / 导出校验 / GD 引用)。确定性:不含时间戳。"""
rules = sort_rules(rules or [])
entries = []
for r in rules:
entries.append({
'rule_id': str(r.get('rule_id') or ''),
'name': str(r.get('name') or ''),
'priority': to_int(r.get('priority'), 0),
'enabled': bool(r.get('enabled')),
'event_type': str((r.get('event') or {}).get('type') or ''),
'condition_count': count_conditions(r.get('condition')),
'response_count': len(r.get('responses') or []),
'content_hash': sha256_text(canonical_dumps(r)),
})
manifest = {
'schema_version': RULE_SCHEMA_VERSION,
'script_type': 1,
'rule_count': len(entries),
'enabled_count': sum(1 for e in entries if e['enabled']),
'disabled_count': sum(1 for e in entries if not e['enabled']),
'response_total': sum(e['response_count'] for e in entries),
'condition_total': sum(e['condition_count'] for e in entries),
'rules_hash': rules_hash(rules),
'rules': entries,
}
if extra:
manifest['extra'] = strip_volatile(extra)
return manifest
def diff_rules(old_rules, new_rules):
"""版本比对added / removed / changed按 content_hash。确定性输出。"""
def _idx(rs):
out = {}
for r in rs or []:
rid = str((r or {}).get('rule_id') or '')
if rid:
out[rid] = sha256_text(canonical_dumps(r))
return out
o, n = _idx(old_rules), _idx(new_rules)
added = sorted(k for k in n if k not in o)
removed = sorted(k for k in o if k not in n)
changed = sorted(k for k in n if k in o and o[k] != n[k])
return {
'added': added, 'removed': removed, 'changed': changed,
'unchanged_count': len([k for k in n if k in o and o[k] == n[k]]),
'added_count': len(added), 'removed_count': len(removed),
'changed_count': len(changed),
'old_rules_hash': rules_hash(old_rules or []),
'new_rules_hash': rules_hash(new_rules or []),
}