pbl_evidence/scripts/selfcheck_m5a.py

881 lines
42 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M5a 自检脚本(pbl_evidence 模块交付门禁)。
用法
----
cd modules/pbl_evidence && python3 scripts/selfcheck_m5a.py
# 全部通过 → 逐条打印 [PASS],以 `ALL PASS (N/N checks)` 收尾,exit 0
# 任一失败 → 打印 [FAIL] + 原因,exit 1
幂等契约(本脚本自身)
--------------------
本脚本**只读**:不连库、不写文件、不打印时间戳/随机数/绝对路径,所有明细一律
`sorted()` 后输出。因此重复执行输出逐字节一致(`diff <(run) <(run)` 为空即证)。
校验项(与任务书一一对应)
------------------------
1. 三处同步:包内定义符号 ⊇ __init__.py __all__ ⊇ init.py env 注册符号,且 env 注册数 ≥ 10
2. RBAC:wwwroot/api/*.dspy 文件名集合 ⊇ wwwroot/index.ui 声明的 api 路径(≥9),且均已在 scripts/load_path.py 显式登记
3. 模型四段式:models/*.json 每文件含 summary(primary 为非空数组)/fields/indexes/codes,且至少 1 个 unique 约束
4. 幂等键语义:uk 列组合 == (tenant_id, source_event_id, evidence_type);dedup_key 在 editexclouded;新增走采集端点
5. pyproject.toml dependencies 不含 apppublic/ahserver/appbase/rbac(基础包由宿主 build.sh 安装)
6. init/data.json 存在且 json.load 通过(禁占位词)
7. C-3 fail-closed:resolve_event_table() 返回空时 collector 必须 raise CollectError 且消息含 'M11b'
8. README 引用的 scripts/*.py 路径全部在磁盘命中(含本脚本自身)
9. harness 静态门禁:main() 外层有 try/except 收敛未捕获异常;raw()/q_all()/q_exec() 不再出现
`args or {}` / `dict(params or {})` 反模式;种子 INSERT 用具名参数 `%(event_id)s` 而非位置 `%s`
10. harness 零明文凭据:password=/user=/test123 等口令字面量 0 命中;db_kwargs() 只从 PBL_M5A_DB
或 projects/pbls/env/test.json 取凭据,取不到即 raise(禁 fallback 到硬编码口令)
11. 运行证据日志:projects/pbls/docs/02-develop/ 下 m5a-live-db-harness.log / m5a-selfcheck.log /
m5a-pytest-offline.log 三份均存在且含 `# RC=0` 收尾标记;harness 日志额外含
`LIVE DB HARNESS ALL PASS` 与 C-3.1~C-3.4、IDM.1~IDM.9 全 [PASS] 行;
.qc_tmp/ 下的崩溃 traceback 日志不计为证据
12. harness DDL 与模型一致:DDL_PBL_EVIDENCE 解析出的列集合 ⊆ models/pbl_evidence.json fields,
且 UNIQUE KEY uk_ev_dedup 列组合 == (tenant_id, source_event_id, evidence_type)
关于「四段式」口径:database-table-definition-spec 规定的四段是
summary/fields/indexes/codes(任务书写的 primary/columns/indexes/constraints 是其
语义等价映射:primary→summary[0].primary、columns→fields、indexes→indexes、
constraints→indexes 中 idxtype='unique' 的唯一约束)。本脚本按规范口径校验,
并逐条断言映射后的四项语义成立。
"""
import ast
import glob
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PKG_DIR = os.path.join(ROOT, 'pbl_evidence')
WWWROOT = os.path.join(ROOT, 'wwwroot')
MODELS_DIR = os.path.join(ROOT, 'models')
JSON_DIR = os.path.join(ROOT, 'json')
HARNESS_PATH = os.path.join(ROOT, 'tests', 'm5a_live_db_harness.py')
# 期望的幂等键三元组(C-3 硬约束)
UK_EXPECTED = ['tenant_id', 'source_event_id', 'evidence_type']
# 禁止出现在 pyproject dependencies 的基础包(非 PyPI,由宿主 build.sh git 安装)
FORBIDDEN_DEPS = ('apppublic', 'appPublic', 'ahserver', 'appbase', 'rbac', 'accounting')
# 三处同步的 10 个对外契约函数(api.py 定义 → __init__ 导出 → init.py 挂载)
CONTRACT_APIS = (
'pbl_artifact_create', 'pbl_artifact_read', 'pbl_artifact_update',
'pbl_artifact_delete', 'pbl_artifact_list',
'pbl_evidence_collect', 'pbl_evidence_collect_from_events',
'pbl_evidence_list', 'pbl_evidence_stats', 'pbl_evidence_watermark',
)
MIN_ENV_REGISTRATIONS = 10
MIN_UI_API_REFS = 9
# ── 第 9~12 项门禁用的常量 ────────────────────────────────────────────
# 项目过程文档落点(相对机构工作空间,见 project-directory-spec 第五章)
DOCS_REL = os.path.join('projects', 'pbls', 'docs', '02-develop')
# 必须存在的三份运行证据日志(只认 docs/02-develop 顶层,.qc_tmp/ 下的一律不算)
EVIDENCE_LOGS = (
'm5a-live-db-harness.log',
'm5a-selfcheck.log',
'm5a-pytest-offline.log',
)
RC_MARKER = '# RC=0'
HARNESS_ALL_PASS_TOKEN = 'LIVE DB HARNESS ALL PASS'
# harness 必须逐条 [PASS] 的断言编号(C-3 fail-closed 4 条 + 幂等 9 条)
HARNESS_CHECK_TOKENS = (
'C-3.1', 'C-3.2', 'C-3.3', 'C-3.4',
'IDM.1', 'IDM.2', 'IDM.3', 'IDM.4', 'IDM.5', 'IDM.6', 'IDM.7', 'IDM.8', 'IDM.9',
)
# 明文口令字面量黑名单(harness 源码里 0 命中才算过)
CREDENTIAL_PATTERNS = (
r"password\s*=\s*['\"]",
r'user\s*=\s*[\'"]test[\'"]',
r"['\"]test123['\"]",
r"passwd\s*=\s*['\"]",
)
# 数据访问层「静默吞参」反模式(会把 None 变成 {} 掩盖调用方漏传参的缺陷)
PARAM_SWALLOW_PATTERNS = ('args or {}', 'params or {}', 'dict(params or {})', 'or {}')
# harness 里需要收敛为断言的 phase 调用(main 外层 try 必须包住它们)
HARNESS_PHASE_CALLS = ('install_stub', 'phase_fail_closed', 'phase_idempotent', 'phase_third_run')
# 崩溃 traceback 特征(出现即说明该日志是崩溃现场,不是通过证据)
TRACEBACK_TOKEN = 'Traceback (most recent call last)'
def _read(path):
with open(path, 'r', encoding='utf-8') as fh:
return fh.read()
def _rel(path):
return os.path.relpath(path, ROOT).replace(os.sep, '/')
def _pkg_py_files():
return sorted(glob.glob(os.path.join(PKG_DIR, '*.py')))
def _top_level_symbols(path):
"""一个 .py 文件里顶层定义的函数/类/模块级常量名集合。"""
tree = ast.parse(_read(path), filename=path)
out = set()
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
out.add(node.name)
elif isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
out.add(tgt.id)
return out
def _dunder_all(path):
"""解析 __init__.py 里的 __all__ 列表字面量。"""
tree = ast.parse(_read(path), filename=path)
for node in tree.body:
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == '__all__':
if isinstance(node.value, (ast.List, ast.Tuple)):
return [e.value for e in node.value.elts
if isinstance(e, ast.Constant) and isinstance(e.value, str)]
return []
def _env_registrations(path, known_symbols=None):
"""解析 init.py 中 `env.<attr> = <symbol>` 注册,返回 {attr: symbol_or_None}。
右侧不止 `= name` 一种合法形态:`env.pbl_evidence_types = list(EVIDENCE_TYPES)`
这种「把包内常量浅拷贝一份再挂载」的写法同样可追溯——取表达式里引用的、且在包内
确有定义的符号名(内建 list/dict 等不算)。只有完全追溯不到包内符号(属性链、
字面量、lambda)才返回 None,由调用方按可疑处理。
"""
tree = ast.parse(_read(path), filename=path)
out = {}
known = set(known_symbols or ())
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for tgt in node.targets:
if (isinstance(tgt, ast.Attribute) and isinstance(tgt.value, ast.Name)
and tgt.value.id == 'env'):
val = node.value
if isinstance(val, ast.Name):
sym = val.id
else:
hits = [n.id for n in ast.walk(val)
if isinstance(n, ast.Name) and n.id in known]
sym = hits[0] if hits else None
out[tgt.attr] = sym
return out
def _funcs_by_name(src, path, names):
"""返回 {name: 源码片段},收集文件内所有指定名字的函数/方法定义(含类内方法)。"""
tree = ast.parse(src, filename=path)
want = set(names)
out = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in want:
seg = ast.get_source_segment(src, node) or ''
# 同名函数(如类方法 vs 顶层)都保留,拼接后一起判定
out[node.name] = (out.get(node.name, '') + '\n' + seg)
return out
def _find_docs_dir():
"""从模块目录向上定位机构工作空间根,返回 docs/02-develop 绝对路径(可能不存在)。"""
cur = ROOT
for _ in range(6):
parent = os.path.dirname(cur)
if parent == cur:
break
cand = os.path.join(parent, DOCS_REL)
if os.path.isdir(cand):
return cand
cur = parent
return os.path.join(ROOT, os.pardir, os.pardir, DOCS_REL)
# ── 校验项 ─────────────────────────────────────────────────────────────
def check_three_place_sync():
"""① 包内定义 ⊇ ② __init__.__all__ ⊇ ③ init.py env 注册(≥10)。"""
defined = set()
api_defined = set()
for f in _pkg_py_files():
syms = _top_level_symbols(f)
defined |= syms
if os.path.basename(f) == 'api.py':
api_defined |= syms
init_py = os.path.join(PKG_DIR, '__init__.py')
load_py = os.path.join(PKG_DIR, 'init.py')
if not os.path.isfile(init_py):
return False, '缺少 %s' % _rel(init_py)
if not os.path.isfile(load_py):
return False, '缺少 %s' % _rel(load_py)
all_names = _dunder_all(init_py)
env_map = _env_registrations(load_py, defined)
problems = []
if not all_names:
problems.append('__init__.py 未声明 __all__')
not_defined = sorted(set(all_names) - defined)
if not_defined:
problems.append('__all__ 中符号在包内无定义: %s' % ', '.join(not_defined))
missing_api = sorted(set(CONTRACT_APIS) - api_defined)
if missing_api:
problems.append('api.py 缺契约函数: %s' % ', '.join(missing_api))
missing_in_all = sorted(set(CONTRACT_APIS) - set(all_names))
if missing_in_all:
problems.append('__all__ 未导出契约函数: %s' % ', '.join(missing_in_all))
env_syms = sorted({s for s in env_map.values() if s})
env_not_in_all = sorted(set(env_syms) - set(all_names))
if env_not_in_all:
problems.append('init.py 注册的符号未在 __all__ 导出: %s' % ', '.join(env_not_in_all))
env_missing_attr = sorted(a for a, s in env_map.items() if not s)
if env_missing_attr:
problems.append('env 注册右侧追溯不到包内符号(可疑): %s' % ', '.join(env_missing_attr))
if len(env_map) < MIN_ENV_REGISTRATIONS:
problems.append('init.py env 注册数 %d < %d' % (len(env_map), MIN_ENV_REGISTRATIONS))
if problems:
return False, '; '.join(problems)
return True, ('包内定义 %d 符号 ⊇ __all__ %d 项 ⊇ env 注册 %d 项;契约函数 %d 个三处齐全;'
'env 注册样例: %s'
% (len(defined), len(all_names), len(env_map), len(CONTRACT_APIS),
', '.join(sorted(env_map)[:5]) + ' ...'))
def check_rbac_dspy_vs_ui():
"""wwwroot/api/*.dspy ⊇ index.ui 声明的 api 路径;且 load_path.py 已显式登记。"""
api_dir = os.path.join(WWWROOT, 'api')
if not os.path.isdir(api_dir):
return False, '缺少目录 %s' % _rel(api_dir)
have = sorted(os.path.basename(p) for p in glob.glob(os.path.join(api_dir, '*.dspy')))
index_ui = os.path.join(WWWROOT, 'index.ui')
if not os.path.isfile(index_ui):
return False, '缺少 %s' % _rel(index_ui)
ui_text = _read(index_ui)
refs = sorted(set(re.findall(r'api/([A-Za-z0-9_]+\.dspy)', ui_text)))
if not refs:
return False, 'index.ui 中未解析到任何 api/*.dspy 引用'
problems = []
if len(refs) < MIN_UI_API_REFS:
problems.append('index.ui 声明的 api 路径数 %d < %d' % (len(refs), MIN_UI_API_REFS))
missing = sorted(set(refs) - set(have))
if missing:
problems.append('index.ui 引用但磁盘缺失: %s' % ', '.join(missing))
lp = os.path.join(ROOT, 'scripts', 'load_path.py')
if os.path.isfile(lp):
lp_text = _read(lp)
unregistered = sorted(r for r in refs if r not in lp_text)
if unregistered:
problems.append('未在 scripts/load_path.py 显式登记(RBAC 403 风险): %s'
% ', '.join(unregistered))
else:
problems.append('缺少 %s(RBAC 无登记入口)' % _rel(lp))
if problems:
return False, '; '.join(problems)
return True, ('磁盘 %d 个 .dspy ⊇ index.ui 声明 %d 个(%s);均已在 load_path.py 逐条登记'
% (len(have), len(refs), ', '.join(refs)))
def check_models_four_sections():
"""models/*.json 四段式(summary/fields/indexes/codes + unique 约束)。"""
files = sorted(glob.glob(os.path.join(MODELS_DIR, '*.json')))
if len(files) < 2:
return False, 'models/*.json 数量 %d < 2' % len(files)
problems = []
detail = []
for f in files:
rel = _rel(f)
try:
data = json.loads(_read(f))
except Exception as exc:
problems.append('%s 不是合法 JSON: %s' % (rel, exc))
continue
for seg in ('summary', 'fields', 'indexes'):
if not isinstance(data.get(seg), list) or not data.get(seg):
problems.append('%s 缺非空数组段 %s' % (rel, seg))
if not isinstance(data.get('codes'), list):
problems.append('%s 缺 codes 段(数组,可为空)' % rel)
summ = (data.get('summary') or [{}])[0]
primary = summ.get('primary')
if not isinstance(primary, list) or not primary:
problems.append('%s summary[0].primary 必须是非空数组' % rel)
names = [fd.get('name') for fd in (data.get('fields') or [])]
if 'id' not in names:
problems.append('%s fields 缺主键列 id' % rel)
for fd in (data.get('fields') or []):
if fd.get('type') in ('str', 'char') and not isinstance(fd.get('length'), int):
problems.append('%s 字段 %s: str/char 缺整数 length' % (rel, fd.get('name')))
if not fd.get('title'):
problems.append('%s 字段 %s 缺 title(DDL COMMENT)' % (rel, fd.get('name')))
idx_names = [i.get('name') for i in (data.get('indexes') or [])]
if len(idx_names) != len(set(idx_names)):
problems.append('%s 索引名重复' % rel)
for idx in (data.get('indexes') or []):
if not isinstance(idx.get('idxfields'), list) or not idx.get('idxfields'):
problems.append('%s 索引 %s 的 idxfields 必须是非空数组' % (rel, idx.get('name')))
unknown = sorted(c for c in (idx.get('idxfields') or []) if c not in names)
if unknown:
problems.append('%s 索引 %s 引用未知列 %s' % (rel, idx.get('name'), ','.join(unknown)))
uniques = sorted(i.get('name') for i in (data.get('indexes') or [])
if i.get('idxtype') == 'unique')
if not uniques:
problems.append('%s 无 unique 约束(constraints 语义缺失)' % rel)
detail.append('%s: primary=%s unique=[%s] fields=%d'
% (os.path.basename(f), primary, ','.join(uniques), len(names)))
if problems:
return False, '; '.join(problems)
return True, ' | '.join(detail)
def check_idempotency_key():
"""uk 列组合 == (tenant_id, source_event_id, evidence_type) 且全链路语义闭环。"""
problems = []
mpath = os.path.join(MODELS_DIR, 'pbl_evidence.json')
if not os.path.isfile(mpath):
return False, '缺少 %s' % _rel(mpath)
model = json.loads(_read(mpath))
uk_indexes = [i for i in (model.get('indexes') or [])
if i.get('idxtype') == 'unique' and str(i.get('name', '')).startswith('uk')]
if len(uk_indexes) != 1:
problems.append('pbl_evidence 唯一索引数 %d != 1' % len(uk_indexes))
uk_cols = list(uk_indexes[0].get('idxfields') or []) if uk_indexes else []
if uk_cols != UK_EXPECTED:
problems.append('uk 列组合 %s != %s' % (uk_cols, UK_EXPECTED))
jpath = os.path.join(JSON_DIR, 'pbl_evidence.json')
if not os.path.isfile(jpath):
problems.append('缺少 %s' % _rel(jpath))
return False, '; '.join(problems)
crud = json.loads(_read(jpath))
params = crud.get('params') or {}
edit_ex = params.get('editexclouded') or []
if 'dedup_key' not in edit_ex:
problems.append('params.editexclouded 未含 dedup_key(去重键可被表单手改)')
# editexclouded 的语义是「把列从表单中排除」。幂等键列必须被排除才安全:
# 旧断言方向写反(要求 tenant_id 出现在表单里),既与 crud_api.py
# 「唯一索引三元组中的 tenant_id 永不开放」矛盾,也会把跨租户漂移的真实
# 缺陷判成 PASS。故正确断言是 tenant_id 必须被屏蔽。
if 'tenant_id' not in edit_ex:
problems.append('tenant_id 未列入 editexclouded(表单可改租户 → 幂等键跨租户漂移)')
# source_event_id / evidence_type 允许人工修订(见 crud_api.EVIDENCE_EDITABLE_COLS),
# 但后端必须在三元组变化时重算 dedup_key,否则与 uk_ev_dedup 漂移。
crud_path = os.path.join(PKG_DIR, 'crud_api.py')
if not os.path.isfile(crud_path):
problems.append('缺少 %s(无法核对 dedup_key 重算守卫)' % _rel(crud_path))
elif 'build_dedup_key(' not in _read(crud_path):
problems.append('crud_api.py 修订三元组后未重算 dedup_key(与 uk_ev_dedup 漂移)')
new_url = str(params.get('new_data_url') or '')
if 'pbl_evidence_collect.dspy' not in new_url:
problems.append('params.new_data_url 未指向采集端点: %s' % new_url)
for key in ('new_data_url', 'update_data_url', 'delete_data_url'):
if key not in params:
problems.append('params 缺 %s(editable 三 URL 须在 params 顶层)' % key)
if str(params.get('logined_userorgid') or '') != 'tenant_id':
problems.append('params.logined_userorgid != tenant_id(列表未按租户隔离)')
# 代码侧闭环:dedup_key 必须由同一三元组派生,且写入 record 含三列
col_src = _read(os.path.join(PKG_DIR, 'collector.py'))
if not re.search(r"build_dedup_key\(\s*tid\s*,\s*src_id\s*,\s*evidence_type\s*\)", col_src):
problems.append('collector.py 未按 (tenant_id, source_event_id, evidence_type) 构造 dedup_key')
for col in UK_EXPECTED:
if re.search(r"'%s'\s*:" % col, col_src) is None:
problems.append('collector.py 写入 record 缺列 %s' % col)
if 'ON DUPLICATE KEY UPDATE' not in col_src:
problems.append('collector.py 缺 ON DUPLICATE KEY UPDATE(并发防重第二层保险)')
if problems:
return False, '; '.join(problems)
return True, ('uk_ev_dedup 列组合 == %s;tenant_id/dedup_key 已在 editexclouded 屏蔽,'
'source_event_id/evidence_type 可人工修订但 crud_api 重算 dedup_key;'
'新增走 %s;写入含 ON DUPLICATE KEY UPDATE 双保险'
% (tuple(UK_EXPECTED), new_url.replace('{{entire_url(', '').replace(')}}', '')))
def check_pyproject_deps():
"""dependencies 不得声明基础包(由宿主 build.sh git 安装,非 PyPI)。"""
pp = os.path.join(ROOT, 'pyproject.toml')
if not os.path.isfile(pp):
return False, '缺少 pyproject.toml'
text = _read(pp)
block = re.search(r'^dependencies\s*=\s*\[([^\]]*)\]', text, re.S | re.M)
if not block:
return False, 'pyproject.toml 未找到 dependencies 数组'
deps = [d.strip().strip('"\'') for d in block.group(1).split(',') if d.strip()]
bad = sorted({d for d in deps
for f in FORBIDDEN_DEPS
if d.split('[')[0].split('>=')[0].strip().lower() == f.lower()})
if bad:
return False, 'dependencies 含基础包(应交由宿主 build.sh 安装): %s' % ', '.join(bad)
if 'sqlor' not in deps:
return False, 'dependencies 缺直接依赖 sqlor'
return True, 'dependencies = [%s],不含 apppublic/ahserver/appbase/rbac' % ', '.join(deps)
def check_init_data():
"""init/data.json 存在且是合法 JSON 的真实种子数据(禁占位符文本)。"""
p = os.path.join(ROOT, 'init', 'data.json')
if not os.path.isfile(p):
return False, '缺少 %s' % _rel(p)
try:
data = json.loads(_read(p))
except Exception as exc:
return False, 'init/data.json 解析失败(部署时 json.load 会中断): %s' % exc
if not isinstance(data, (dict, list)) or not data:
return False, 'init/data.json 内容为空或不是对象/数组(禁止占位符文本)'
text = json.dumps(data, ensure_ascii=False)
for placeholder in ('待明确', '待确认', 'TBD', 'placeholder'):
if placeholder in text:
return False, 'init/data.json 含占位词 %s' % placeholder
groups = data.get('appcodes') if isinstance(data, dict) else None
if isinstance(groups, list):
problems = []
n_items = 0
for g in groups:
pid = str(g.get('parentid') or '')
items = g.get('items') or []
n_items += len(items)
if not pid or not items:
problems.append('组 %s 缺 parentid 或 items' % (pid or '?'))
continue
longest_k = max(len(str(i.get('k') or '')) for i in items)
if len(pid) + 1 + longest_k > 32:
problems.append('parentid=%s 过长:id 生成 %s_%%s 超 VARCHAR(32)' % (pid, pid))
for i in items:
if not i.get('k') or not i.get('v'):
problems.append('组 %s 存在缺 k/v 的条目' % pid)
if problems:
return False, '; '.join(problems)
return True, ('init/data.json 合法:appcodes %d 组 / kv %d 项(parentid 长度合规): %s'
% (len(groups), n_items, ', '.join(sorted(str(g.get('parentid')) for g in groups))))
return True, 'init/data.json 合法(顶层键 %s)' % ', '.join(sorted(data.keys())[:6])
def _find_fail_closed_guard(src, path):
"""在 collect_evidence_from_events 中定位「事件表缺失 → raise CollectError(M11b)」守卫。"""
tree = ast.parse(src, filename=path)
fn = None
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'collect_evidence_from_events':
fn = node
break
if fn is None:
return False, '未定义 collect_evidence_from_events'
# 找出 `X = await resolve_event_table()` 中被赋值的变量名
resolved_names = set()
for node in ast.walk(fn):
if isinstance(node, (ast.Assign, ast.AnnAssign)):
call = node.value
if isinstance(call, ast.Await):
call = call.value
if isinstance(call, ast.Call):
fname = getattr(call.func, 'id', None) or getattr(call.func, 'attr', None)
if fname == 'resolve_event_table':
tgts = [node.target] if isinstance(node, ast.AnnAssign) else node.targets
for t in tgts:
if isinstance(t, ast.Name):
resolved_names.add(t.id)
if not resolved_names:
return False, 'collect_evidence_from_events 未调用 resolve_event_table()'
for node in ast.walk(fn):
if not isinstance(node, ast.If):
continue
test = node.test
if (isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not)
and isinstance(test.operand, ast.Name) and test.operand.id in resolved_names):
seg = ast.get_source_segment(src, node) or ''
has_raise = 'raise CollectError' in seg
has_m11b = 'M11b' in seg
if has_raise and has_m11b:
return True, '守卫 `if not %s: raise CollectError(...M11b...)` 成立' % test.operand.id
return False, ('变量 %s 由 resolve_event_table() 赋值,但其空值分支未 raise 含 M11b 的 '
'CollectError(可能空成功返回)' % ', '.join(sorted(resolved_names)))
def check_fail_closed_source():
"""C-3:事件表缺失时 collector 必须 raise CollectError 且消息含 'M11b'。"""
p = os.path.join(PKG_DIR, 'collector.py')
if not os.path.isfile(p):
return False, '缺少 collector.py'
src = _read(p)
problems = []
if 'class CollectError' not in src:
problems.append('未定义 CollectError')
if "'pbl_runtime_event'" not in src:
problems.append("EVENT_TABLE_CANDIDATES 未包含 'pbl_runtime_event'")
ok, detail = _find_fail_closed_guard(src, p)
if not ok:
problems.append(detail)
if problems:
return False, '; '.join(problems)
return True, detail
def check_readme_reference():
"""README 引用的 scripts/*.py 必须在磁盘命中(含本脚本自身)。"""
p = os.path.join(ROOT, 'README.md')
if not os.path.isfile(p):
return False, '缺少 README.md'
text = _read(p)
# README 里同时存在两类 scripts/*.py 引用:①本模块自己的脚本(必须磁盘命中);
# ②宿主/中央应用(apps/pbls 等)的同名目录脚本,按设计不在本模块仓库内。
# 旧逻辑不区分两类,把宿主侧路径也拿来查本模块磁盘,必然误报。
local, external = set(), set()
for m in re.finditer(r'((?:[A-Za-z0-9_./-]*/)?)scripts/[A-Za-z0-9_./-]+\.py', text):
token = m.group(0)
before = text[max(0, m.start() - 24):m.start()]
if 'apps/' in token or token.count('/') > 1 or re.search(r'宿主|中央', before):
external.add(token)
else:
local.add(token)
refs = sorted(local)
missing = [r for r in refs if not os.path.isfile(os.path.join(ROOT, r))]
if missing:
return False, 'README 引用但磁盘缺失: %s' % ', '.join(missing)
if 'scripts/selfcheck_m5a.py' not in refs:
return False, 'README 未引用 scripts/selfcheck_m5a.py(自检入口缺失)'
return True, ('README 引用 %d 个本模块脚本路径全部命中(%d 个宿主侧路径按外部引用豁免): %s'
% (len(refs), len(external), ', '.join(refs)))
# ── 第 9~12 项:harness 与运行证据门禁(QC #4 退回项)─────────────────
def check_harness_static():
"""⑨ harness 源码静态门禁:异常收敛 + 无吞参反模式 + 种子 INSERT 具名参数。
三项都只读源码做 AST/正则判定,不执行 harness(本脚本不连库、不跑子进程)。
"""
if not os.path.isfile(HARNESS_PATH):
return False, '缺少 %s' % _rel(HARNESS_PATH)
src = _read(HARNESS_PATH)
try:
tree = ast.parse(src, filename=HARNESS_PATH)
except Exception as exc:
return False, '%s 语法错误,无法静态校验: %s' % (_rel(HARNESS_PATH), exc)
problems = []
detail = []
# (a) main() 外层 try/except 收敛未捕获异常:崩溃必须变成 FAIL 断言而非 traceback 逃逸
main_fn = None
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'main':
main_fn = node
break
if main_fn is None:
problems.append('harness 未定义 main()')
else:
converged = None
for node in ast.walk(main_fn):
if not isinstance(node, ast.Try):
continue
body_src = ast.get_source_segment(src, node) or ''
if not any(c in body_src for c in HARNESS_PHASE_CALLS):
continue # 只认包住 phase 调用的「外层」try
for handler in node.handlers:
htype = getattr(handler.type, 'id', None) or getattr(handler.type, 'attr', None)
if htype not in ('Exception', 'BaseException'):
continue
hsrc = ast.get_source_segment(src, handler) or ''
# 收敛 = 记失败/给出非零退出,而不是原样 raise 或静默 pass
if re.search(r'\braise\b', hsrc) and 'check(' not in hsrc:
continue
if 'check(' in hsrc or 'return 1' in hsrc or 'sys.exit(1)' in hsrc \
or 'FAILED' in hsrc or 'format_exc' in hsrc:
converged = handler
break
if converged is not None:
break
if converged is None:
problems.append('main() 缺少包住 phase 调用的外层 try/except Exception 收敛'
'(未捕获异常会以 traceback 崩溃逃逸,日志变 .qc_tmp 不算证据)')
else:
detail.append('main() 外层 try/except 收敛未捕获异常')
# (b) raw()/q_all()/q_exec() 不再出现 `args or {}` / `dict(params or {})` 吞参反模式
funcs = _funcs_by_name(src, HARNESS_PATH, ('raw', 'q_all', 'q_exec'))
missing_fn = sorted(set(('raw', 'q_all', 'q_exec')) - set(funcs))
if missing_fn:
problems.append('harness 缺函数 %s' % ', '.join(missing_fn))
else:
bad = []
for name in sorted(funcs):
for pat in PARAM_SWALLOW_PATTERNS:
if pat in funcs[name]:
bad.append('%s() 含 %s' % (name, pat))
if bad:
problems.append('数据访问层静默吞参反模式(None→{} 掩盖漏传参): %s' % '; '.join(sorted(bad)))
else:
detail.append('raw()/q_all()/q_exec() 无 args or {} / dict(params or {}) 反模式')
# (c) 种子 INSERT 用具名参数 %(event_id)s,而非位置 %s(位置参数与列序耦合,改列即错位)
seed_hits = list(re.finditer(r'INSERT\s+INTO\s+`?pbl_runtime_event`?', src, re.I))
if not seed_hits:
problems.append('harness 中未找到 pbl_runtime_event 种子 INSERT 语句')
else:
named_ok = 0
positional = []
for m in seed_hits:
window = src[m.start():m.start() + 600]
flat = re.sub(r'\s+', ' ', window)
if '%(event_id)s' in window:
named_ok += 1
if re.search(r'VALUES\s*\(\s*%s\s*(,|\))', flat):
positional.append(flat[:60])
if named_ok == 0:
problems.append('种子 INSERT 未使用具名参数 %%(event_id)s(%d 处均为位置参数)'
% len(seed_hits))
elif named_ok < len(seed_hits):
problems.append('种子 INSERT 具名/位置混用:具名 %d 处 / 共 %d 处'
% (named_ok, len(seed_hits)))
else:
detail.append('种子 INSERT %d 处均用具名参数 %%(event_id)s' % named_ok)
if positional:
problems.append('种子 INSERT 仍含位置参数 VALUES (%s,...):%d 处'
% ('%s', len(positional)))
if problems:
return False, '; '.join(problems)
return True, ' | '.join(detail)
def check_no_hardcoded_credentials():
"""⑩ harness 零明文凭据:口令字面量 0 命中;db_kwargs() 只从 env 变量/env/test.json 取。
环境信息唯一事实源是 projects/pbls/env/test.json(project-directory-spec 第八章),
应用/模块仓库内不得内嵌部署凭据——写死 fallback 口令既泄密又造成多份矛盾环境。
"""
if not os.path.isfile(HARNESS_PATH):
return False, '缺少 %s' % _rel(HARNESS_PATH)
src = _read(HARNESS_PATH)
problems = []
detail = []
hits = []
for lineno, line in enumerate(src.splitlines(), 1):
for pat in CREDENTIAL_PATTERNS:
if re.search(pat, line, re.I):
hits.append('%s@L%d' % (re.sub(r'\s+', '', pat), lineno))
if hits:
problems.append('harness 源码命中明文口令字面量 %d 处: %s'
% (len(hits), ', '.join(sorted(set(hits)))))
else:
detail.append('password=/user=/test123 等明文凭据字面量 0 命中')
try:
tree = ast.parse(src, filename=HARNESS_PATH)
except Exception as exc:
problems.append('harness 语法错误,无法解析 db_kwargs(): %s' % exc)
return False, '; '.join(problems)
db_kwargs_src = ''
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'db_kwargs':
db_kwargs_src += (ast.get_source_segment(src, node) or '')
if not db_kwargs_src:
problems.append('harness 未定义 db_kwargs()(凭据来源无从校验)')
else:
has_env_src = ('PBL_M5A_DB' in db_kwargs_src) or ('test.json' in db_kwargs_src)
if not has_env_src:
problems.append("db_kwargs() 未从 PBL_M5A_DB 或 env/test.json 读取凭据"
"(唯一事实源缺失,只能靠硬编码)")
else:
srcs = []
if 'PBL_M5A_DB' in db_kwargs_src:
srcs.append('PBL_M5A_DB')
if 'test.json' in db_kwargs_src:
srcs.append('env/test.json')
detail.append('db_kwargs() 凭据来源: %s' % ' / '.join(srcs))
# 取不到必须 raise:静默回落到硬编码口令 = 用错库还自称 PASS
if 'raise' not in db_kwargs_src:
problems.append('db_kwargs() 取不到凭据时未 raise(会静默回落到硬编码口令连错库)')
else:
detail.append('db_kwargs() 缺凭据即 raise,无静默回落')
if problems:
return False, '; '.join(problems)
return True, ' | '.join(detail)
def check_evidence_logs():
"""⑪ 运行证据日志:三份日志齐备且含 `# RC=0`;harness 日志含 ALL PASS 与全 [PASS] 行。
只认 projects/pbls/docs/02-develop/ 顶层文件:.qc_tmp/ 下的是崩溃 traceback 现场,
属于「跑挂了」的记录而非「跑过了」的证据,计入会把失败伪装成交付。
"""
docs = _find_docs_dir()
if not os.path.isdir(docs):
return False, '缺少证据目录 %s(相对工作空间 projects/pbls/docs/02-develop)' % DOCS_REL.replace(os.sep, '/')
top = sorted(f for f in os.listdir(docs)
if os.path.isfile(os.path.join(docs, f)))
problems = []
detail = []
# .qc_tmp/ 下的同名文件不算证据(显式统计出来,便于定位「日志放错地方」)
qc_dir = os.path.join(docs, '.qc_tmp')
qc_names = sorted(f for f in (os.listdir(qc_dir) if os.path.isdir(qc_dir) else []))
qc_only = sorted(set(EVIDENCE_LOGS) & set(qc_names) - set(top))
contents = {}
for name in EVIDENCE_LOGS:
if name not in top:
where = '(仅存在于 .qc_tmp/,不计为证据)' if name in qc_only else ''
problems.append('缺少证据日志 %s%s' % (name, where))
continue
text = _read(os.path.join(docs, name))
contents[name] = text
if RC_MARKER not in text:
problems.append('%s 缺 `%s` 收尾标记(未记录退出码=无法证明 rc=0)' % (name, RC_MARKER))
if TRACEBACK_TOKEN in text and name != 'm5a-live-db-harness.log':
problems.append('%s 含崩溃 traceback,非通过证据' % name)
if problems:
return False, '; '.join(problems)
detail.append('三份证据日志齐备且均含 `%s`: %s' % (RC_MARKER, ', '.join(sorted(EVIDENCE_LOGS))))
hlog = contents['m5a-live-db-harness.log']
if HARNESS_ALL_PASS_TOKEN not in hlog:
problems.append('harness 日志缺 `%s` 收尾行' % HARNESS_ALL_PASS_TOKEN)
if TRACEBACK_TOKEN in hlog:
problems.append('harness 日志含崩溃 traceback(未收敛的未捕获异常)')
missing_pass = sorted(t for t in HARNESS_CHECK_TOKENS
if not re.search(r'\[PASS\]\s*' + re.escape(t) + r'\b', hlog))
if missing_pass:
problems.append('harness 日志缺 [PASS] 断言行: %s' % ', '.join(missing_pass))
else:
detail.append('harness 日志含 %s 与 C-3.1~C-3.4 / IDM.1~IDM.9 共 %d 条 [PASS] 行'
% (HARNESS_ALL_PASS_TOKEN, len(HARNESS_CHECK_TOKENS)))
if problems:
return False, '; '.join(problems)
return True, ' | '.join(detail)
def _parse_ddl_columns(ddl):
"""从 CREATE TABLE 正文里取「列定义」名(排除 PRIMARY/UNIQUE/KEY/CONSTRAINT 等索引行)。"""
body = re.search(r'\((.*)\)\s*(?:ENGINE|COMMENT|=|$)', ddl, re.S)
seg = body.group(1) if body else ddl
cols = []
for line in seg.splitlines():
s = line.strip().rstrip(',')
m = re.match(r'`(\w+)`\s+[A-Za-z]', s)
if not m:
continue
if re.match(r'(PRIMARY|UNIQUE|KEY|CONSTRAINT|INDEX)\b', s, re.I):
continue
cols.append(m.group(1))
return cols
def _parse_ddl_unique(ddl, uk_name):
"""取 `UNIQUE KEY \\`uk_ev_dedup\\` (\\`a\\`, \\`b\\`)` 的列组合(保序)。"""
m = re.search(r'UNIQUE\s+KEY\s+`?%s`?\s*\(([^)]*)\)' % re.escape(uk_name), ddl, re.I)
if not m:
return None
return [c.strip().strip('`').strip() for c in m.group(1).split(',') if c.strip()]
def check_harness_matches_model():
"""⑫ harness 沙箱 DDL 与 models/pbl_evidence.json 对齐(列 ⊆ 模型列、uk 三元组一致)。
harness 自建表跑出来的幂等结论,只有与真源(models/*.json)同构才有效;否则「沙箱
里幂等、生产上漂移」会被日志证据掩盖。
"""
problems = []
if not os.path.isfile(HARNESS_PATH):
return False, '缺少 %s' % _rel(HARNESS_PATH)
src = _read(HARNESS_PATH)
try:
tree = ast.parse(src, filename=HARNESS_PATH)
except Exception as exc:
return False, 'harness 语法错误,无法解析 DDL_PBL_EVIDENCE: %s' % exc
ddl = None
for node in tree.body:
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == 'DDL_PBL_EVIDENCE':
v = node.value
if isinstance(v, ast.Constant) and isinstance(v.value, str):
ddl = v.value
if ddl is None:
return False, 'harness 未找到模块级常量 DDL_PBL_EVIDENCE(字符串字面量)'
mpath = os.path.join(MODELS_DIR, 'pbl_evidence.json')
if not os.path.isfile(mpath):
return False, '缺少 %s' % _rel(mpath)
model = json.loads(_read(mpath))
model_cols = [fd.get('name') for fd in (model.get('fields') or []) if fd.get('name')]
uk_idx = [i for i in (model.get('indexes') or []) if i.get('name') == 'uk_ev_dedup']
if len(uk_idx) != 1:
problems.append('models/pbl_evidence.json 中 uk_ev_dedup 索引数 %d != 1' % len(uk_idx))
model_uk = list(uk_idx[0].get('idxfields') or []) if uk_idx else []
ddl_cols = _parse_ddl_columns(ddl)
ddl_uk = _parse_ddl_unique(ddl, 'uk_ev_dedup')
if not ddl_cols:
problems.append('DDL_PBL_EVIDENCE 未解析出任何列定义')
unknown = sorted(set(ddl_cols) - set(model_cols))
if unknown:
problems.append('DDL 列不在模型 fields 中: %s' % ', '.join(unknown))
if sorted(set(ddl_uk or [])) != sorted(UK_EXPECTED):
problems.append('DDL uk_ev_dedup 列组合 %s != %s' % (ddl_uk, UK_EXPECTED))
if model_uk and ddl_uk and list(ddl_uk) != list(model_uk):
problems.append('DDL uk_ev_dedup 列序 %s != 模型 idxfields %s' % (ddl_uk, model_uk))
if problems:
return False, '; '.join(problems)
return True, ('DDL_PBL_EVIDENCE %d 列 ⊆ models/pbl_evidence.json %d 列;'
'uk_ev_dedup == %s(与模型 idxfields 逐列一致)'
% (len(ddl_cols), len(model_cols), tuple(ddl_uk)))
CHECKS = [
('三处同步:包内定义 ⊇ __init__.__all__ ⊇ init.py env 注册(≥10)', check_three_place_sync),
('RBAC:wwwroot/api/*.dspy ⊇ index.ui 声明路径(≥9)且逐条登记 load_path', check_rbac_dspy_vs_ui),
('模型四段式:models/*.json summary(primary)/fields/indexes/codes + unique', check_models_four_sections),
('幂等键语义:uk == (tenant_id, source_event_id, evidence_type) 全链路闭环', check_idempotency_key),
('pyproject.toml dependencies 不含 apppublic/ahserver/appbase/rbac', check_pyproject_deps),
('init/data.json 存在且 json.load 通过(无占位词、parentid 不超长)', check_init_data),
('C-3 fail-closed:事件表缺失时抛 CollectError 且消息含 M11b', check_fail_closed_source),
('README 引用的 scripts/*.py 路径全部磁盘命中(含 selfcheck_m5a.py)', check_readme_reference),
('harness 静态门禁:main() 外层 try/except 收敛 + 无吞参反模式 + 种子 INSERT 具名参数', check_harness_static),
('harness 零明文凭据:无 password/test123 字面量,db_kwargs() 只读 PBL_M5A_DB/env test.json 且缺即 raise', check_no_hardcoded_credentials),
('运行证据日志:docs/02-develop 三份日志含 # RC=0,harness 日志含 ALL PASS 与 C-3.x/IDM.x 全 [PASS]', check_evidence_logs),
('harness DDL 与模型一致:DDL 列 ⊆ models fields 且 uk_ev_dedup == (tenant_id, source_event_id, evidence_type)', check_harness_matches_model),
]
def main():
print('selfcheck_m5a: target = modules/pbl_evidence')
failed = []
for i, (name, fn) in enumerate(CHECKS, 1):
try:
ok, detail = fn()
except SystemExit:
raise
except Exception as exc:
ok, detail = False, '校验执行异常 %s: %s' % (type(exc).__name__, exc)
tag = 'PASS' if ok else 'FAIL'
print('[%s] %d. %s' % (tag, i, name))
print(' %s' % detail)
if not ok:
failed.append(name)
if failed:
print('SELF CHECK FAILED (%d/%d)' % (len(failed), len(CHECKS)))
for n in failed:
print(' - %s' % n)
return 1
print('ALL PASS (%d/%d checks)' % (len(CHECKS), len(CHECKS)))
return 0
if __name__ == '__main__':
sys.exit(main())