approve: 编写并执行幂等/C-3 fail-closed 测试

This commit is contained in:
agent.develop 2026-09-22 18:42:13 +08:00
parent 886db46b9f
commit 0f5e22fe51
2 changed files with 520 additions and 41 deletions

View File

@ -23,6 +23,16 @@
6. init/data.json 存在且 json.load 通过(禁占位词) 6. init/data.json 存在且 json.load 通过(禁占位词)
7. C-3 fail-closed:resolve_event_table() 返回空时 collector 必须 raise CollectError 且消息含 'M11b' 7. C-3 fail-closed:resolve_event_table() 返回空时 collector 必须 raise CollectError 且消息含 'M11b'
8. README 引用的 scripts/*.py 路径全部在磁盘命中(含本脚本自身) 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 规定的四段是 关于「四段式」口径:database-table-definition-spec 规定的四段是
summary/fields/indexes/codes(任务书写的 primary/columns/indexes/constraints 是其 summary/fields/indexes/codes(任务书写的 primary/columns/indexes/constraints 是其
@ -43,6 +53,7 @@ PKG_DIR = os.path.join(ROOT, 'pbl_evidence')
WWWROOT = os.path.join(ROOT, 'wwwroot') WWWROOT = os.path.join(ROOT, 'wwwroot')
MODELS_DIR = os.path.join(ROOT, 'models') MODELS_DIR = os.path.join(ROOT, 'models')
JSON_DIR = os.path.join(ROOT, 'json') JSON_DIR = os.path.join(ROOT, 'json')
HARNESS_PATH = os.path.join(ROOT, 'tests', 'm5a_live_db_harness.py')
# 期望的幂等键三元组(C-3 硬约束) # 期望的幂等键三元组(C-3 硬约束)
UK_EXPECTED = ['tenant_id', 'source_event_id', 'evidence_type'] UK_EXPECTED = ['tenant_id', 'source_event_id', 'evidence_type']
@ -58,6 +69,36 @@ CONTRACT_APIS = (
MIN_ENV_REGISTRATIONS = 10 MIN_ENV_REGISTRATIONS = 10
MIN_UI_API_REFS = 9 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): def _read(path):
with open(path, 'r', encoding='utf-8') as fh: with open(path, 'r', encoding='utf-8') as fh:
@ -127,6 +168,33 @@ def _env_registrations(path, known_symbols=None):
return out 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(): def check_three_place_sync():
"""① 包内定义 ⊇ ② __init__.__all__ ⊇ ③ init.py env 注册(≥10)。""" """① 包内定义 ⊇ ② __init__.__all__ ⊇ ③ init.py env 注册(≥10)。"""
@ -476,6 +544,298 @@ def check_readme_reference():
% (len(refs), len(external), ', '.join(refs))) % (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 = [ CHECKS = [
('三处同步:包内定义 ⊇ __init__.__all__ ⊇ init.py env 注册(≥10)', check_three_place_sync), ('三处同步:包内定义 ⊇ __init__.__all__ ⊇ init.py env 注册(≥10)', check_three_place_sync),
('RBAC:wwwroot/api/*.dspy ⊇ index.ui 声明路径(≥9)且逐条登记 load_path', check_rbac_dspy_vs_ui), ('RBAC:wwwroot/api/*.dspy ⊇ index.ui 声明路径(≥9)且逐条登记 load_path', check_rbac_dspy_vs_ui),
@ -485,6 +845,10 @@ CHECKS = [
('init/data.json 存在且 json.load 通过(无占位词、parentid 不超长)', check_init_data), ('init/data.json 存在且 json.load 通过(无占位词、parentid 不超长)', check_init_data),
('C-3 fail-closed:事件表缺失时抛 CollectError 且消息含 M11b', check_fail_closed_source), ('C-3 fail-closed:事件表缺失时抛 CollectError 且消息含 M11b', check_fail_closed_source),
('README 引用的 scripts/*.py 路径全部磁盘命中(含 selfcheck_m5a.py)', check_readme_reference), ('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),
] ]

View File

@ -19,15 +19,23 @@
python3 tests/m5a_live_db_harness.py # 跑完自动清理沙箱库 python3 tests/m5a_live_db_harness.py # 跑完自动清理沙箱库
python3 tests/m5a_live_db_harness.py --keep # 保留沙箱库供人工核对 python3 tests/m5a_live_db_harness.py --keep # 保留沙箱库供人工核对
PBL_M5A_DB=host:port:user:pwd 覆盖默认 test/test123@127.0.0.1:3306
退出码:0 = 全部断言通过(stdout 末行 ``LIVE DB HARNESS ALL PASS``);1 = 失败。 沙箱库凭据(**代码内零明文口令**,唯一事实源二选一,取不到即 fail-fast)::
1) 环境变量 PBL_M5A_DB=host:port:user:pwd
2) 环境文件 projects/pbls/env/test.json 的 db.sandbox 段(scope=sandbox_only)
该沙箱账号**只用于一次性沙箱 schema**(本脚本自建库、跑完 DROP DATABASE),
不接触业务库、不承载任何应用运行时连接。日志对口令做脱敏,绝不打印。
退出码:0 = 全部断言通过(stdout 末行 ``LIVE DB HARNESS ALL PASS``);1 = 失败
(含未捕获异常——统一收敛为显式 FAIL 项,不靠 traceback 截断证据)。
""" """
import argparse import argparse
import contextlib
import importlib import importlib
import io import io
import json
import os import os
import pathlib import pathlib
import re import re
@ -36,7 +44,9 @@ import traceback
import types import types
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent # modules/pbl_evidence REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent # modules/pbl_evidence
WORKSPACE_ROOT = REPO_ROOT.parent.parent # 机构工作空间
PKG_DIR = REPO_ROOT / 'pbl_evidence' PKG_DIR = REPO_ROOT / 'pbl_evidence'
ENV_FILE_DEFAULT = str(WORKSPACE_ROOT / 'projects' / 'pbls' / 'env' / 'test.json')
SANDBOX_SCHEMA = os.environ.get('PBL_M5A_SANDBOX', 'pbl_m5a_u7rb') SANDBOX_SCHEMA = os.environ.get('PBL_M5A_SANDBOX', 'pbl_m5a_u7rb')
_TENANT = 't1' _TENANT = 't1'
@ -83,18 +93,39 @@ CREATE TABLE `pbl_runtime_event` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
""" """
# 种子事件:具名字段 + 字典(pymysql 具名参数 %(field)s),避免位置元组与
# 列数不一致时触发 "not enough arguments for format string" 类崩溃。
SEED_FIELDS = ('event_id', 'tenant_id', 'event_type', 'payload_json', 'occurred_at',
'learner_id', 'session_id', 'blueprint_id', 'artifact_id')
EVENTS_SEED = ( EVENTS_SEED = (
(_EVENT_ID_A, 'artifact_submitted', {'event_id': _EVENT_ID_A, 'tenant_id': _TENANT, 'event_type': 'artifact_submitted',
'{"title":"bridge-design","score":88}', '2026-09-22 10:00:00', 'stu_01', 'sess_01', 'bp_01', 'art_01'), 'payload_json': '{"title":"bridge-design","score":88}',
(_EVENT_ID_B, 'team_message_sent', 'occurred_at': '2026-09-22 10:00:00', 'learner_id': 'stu_01',
'{"channel":"team","chars":120}', '2026-09-22 10:05:00', 'stu_01', 'sess_01', 'bp_01', '0'), 'session_id': 'sess_01', 'blueprint_id': 'bp_01', 'artifact_id': 'art_01'},
{'event_id': _EVENT_ID_B, 'tenant_id': _TENANT, 'event_type': 'team_message_sent',
'payload_json': '{"channel":"team","chars":120}',
'occurred_at': '2026-09-22 10:05:00', 'learner_id': 'stu_01',
'session_id': 'sess_01', 'blueprint_id': 'bp_01', 'artifact_id': '0'},
) )
_failures = [] _failures = []
_secret_values = set() # 运行期已解析出的口令,日志统一脱敏
_CRED_SOURCE = ['(未解析)'] # 凭据来源标签(只记来源,不记口令)
def _mask(text):
"""把任何可能混入日志/异常消息的口令替换成 ******。"""
out = str(text)
for secret in _secret_values:
if secret:
out = out.replace(secret, '******')
return out
def check(name, ok, detail=''): def check(name, ok, detail=''):
"""记录一条断言并原样打印(PASS/FAIL),失败不中断,最后统一 exit 1。""" """记录一条断言并原样打印(PASS/FAIL),失败不中断,最后统一 exit 1。"""
detail = _mask(detail)
print('[%s] %s%s' % ('PASS' if ok else 'FAIL', name, print('[%s] %s%s' % ('PASS' if ok else 'FAIL', name,
(' -> ' + detail) if detail else '')) (' -> ' + detail) if detail else ''))
if not ok: if not ok:
@ -167,9 +198,14 @@ class Sandbox:
cur.execute(stmt) cur.execute(stmt)
def raw(self, sql, args=None): def raw(self, sql, args=None):
"""执行并返回 list[dict](原样回显用)。""" """执行并返回 list[dict](原样回显用)。
注意:无参 SQL 必须传 None 而不是空 dict —— DictCursor 下传 ``{}``
会让 pymysql 走 ``query % {}`` 格式化,SQL 里任何字面 ``%``(LIKE/DATE_FORMAT)
都会抛 ``TypeError: not enough arguments for format string``。
"""
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
cur.execute(sql, args or {}) cur.execute(sql, args if args else None)
try: try:
return list(cur.fetchall() or []) return list(cur.fetchall() or [])
except Exception: except Exception:
@ -182,7 +218,7 @@ class Sandbox:
def q_all(self, sql, params=None): def q_all(self, sql, params=None):
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
cur.execute(self.to_pymysql(sql), dict(params or {})) cur.execute(self.to_pymysql(sql), dict(params) if params else None)
return [dict(r) for r in (cur.fetchall() or [])] return [dict(r) for r in (cur.fetchall() or [])]
def q_one(self, sql, params=None): def q_one(self, sql, params=None):
@ -191,7 +227,7 @@ class Sandbox:
def q_exec(self, sql, params=None): def q_exec(self, sql, params=None):
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
n = cur.execute(self.to_pymysql(sql), dict(params or {})) n = cur.execute(self.to_pymysql(sql), dict(params) if params else None)
return {'affected_rows': n} return {'affected_rows': n}
def table_exists(self, table): def table_exists(self, table):
@ -258,14 +294,14 @@ def phase_fail_closed(collector, box):
tb_text = ''.join(traceback.format_exc()) tb_text = ''.join(traceback.format_exc())
print('抛出的异常类型: %s' % (type(err).__name__ if err else 'None(未抛错=静默空成功)')) print('抛出的异常类型: %s' % (type(err).__name__ if err else 'None(未抛错=静默空成功)'))
print('异常消息原文: %s' % (str(err) if err else '<无>')) print('异常消息原文: %s' % (_mask(str(err)) if err else '<无>'))
if tb_text: if tb_text:
print('traceback 原文:') print('traceback 原文:')
for line in tb_text.rstrip().splitlines(): for line in tb_text.rstrip().splitlines():
print(' | ' + line) print(' | ' + _mask(line))
check('C-3.2 collect_evidence_from_events(tenant_id="t1") 抛 CollectError', check('C-3.2 collect_evidence_from_events(tenant_id="t1") 抛 CollectError',
isinstance(err, collector.CollectError), type(err).__name__ if err else 'no raise') isinstance(err, collector.CollectError), type(err).__name__ if err else 'no raise')
msg = str(err or '') msg = _mask(str(err or ''))
check("C-3.3 异常消息含 'M11b'(硬依赖声明,非空成功)", 'M11b' in msg, check("C-3.3 异常消息含 'M11b'(硬依赖声明,非空成功)", 'M11b' in msg,
'消息=%s' % msg[:120]) '消息=%s' % msg[:120])
check('C-3.4 未产生任何证据行(fail-closed 不写脏数据)', check('C-3.4 未产生任何证据行(fail-closed 不写脏数据)',
@ -286,12 +322,15 @@ def phase_idempotent(collector, box):
.format(r.get('Seq_in_index'), r.get('Column_name'), .format(r.get('Seq_in_index'), r.get('Column_name'),
r.get('Non_unique'), r.get('Key_name'))) r.get('Non_unique'), r.get('Key_name')))
# 具名参数插入(列名与占位符同源 SEED_FIELDS,列/值不可能错位)
seed_cols = ', '.join('`' + f + '`' for f in SEED_FIELDS)
seed_vals = ', '.join('%(' + f + ')s' for f in SEED_FIELDS)
seed_sql = ('INSERT INTO `pbl_runtime_event` (' + seed_cols + ') VALUES ('
+ seed_vals + ')')
print('事件种子 INSERT(具名参数): %s' % seed_sql)
with box.conn.cursor() as cur: with box.conn.cursor() as cur:
for ev in EVENTS_SEED: for ev in EVENTS_SEED:
cur.execute( cur.execute(seed_sql, dict(ev))
'INSERT INTO `pbl_runtime_event` (event_id, tenant_id, event_type, '
'payload_json, occurred_at, learner_id, session_id, blueprint_id, '
'artifact_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)', ev)
print('事件表写入 %d 行(pbl_runtime_event)' % len(EVENTS_SEED)) print('事件表写入 %d 行(pbl_runtime_event)' % len(EVENTS_SEED))
import asyncio import asyncio
@ -338,11 +377,13 @@ def phase_idempotent(collector, box):
cur.execute( cur.execute(
'INSERT INTO `pbl_evidence` (id, tenant_id, source_event_id, ' 'INSERT INTO `pbl_evidence` (id, tenant_id, source_event_id, '
'evidence_type, occurred_at, dedup_key) ' 'evidence_type, occurred_at, dedup_key) '
'VALUES (%s,%s,%s,%s,%s,%s)', 'VALUES (%(id)s,%(tenant_id)s,%(source_event_id)s,'
('manual_dup_1', _TENANT, _EVENT_ID_A, 'artifact', '%(evidence_type)s,%(occurred_at)s,%(dedup_key)s)',
'2026-09-22 10:00:00', 'x' * 32)) {'id': 'manual_dup_1', 'tenant_id': _TENANT,
'source_event_id': _EVENT_ID_A, 'evidence_type': 'artifact',
'occurred_at': '2026-09-22 10:00:00', 'dedup_key': 'x' * 32})
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
dup_err = '%s: %s' % (type(exc).__name__, exc) dup_err = '%s: %s' % (type(exc).__name__, _mask(exc))
print('直接重复 INSERT 的 DB 原始报错(uk_ev_dedup 真防重): %s' print('直接重复 INSERT 的 DB 原始报错(uk_ev_dedup 真防重): %s'
% (dup_err or '<未报错>')) % (dup_err or '<未报错>'))
check('IDM.6 唯一键 uk_ev_dedup 在 DB 层拦截重复三元组(1062 Duplicate entry)', check('IDM.6 唯一键 uk_ev_dedup 在 DB 层拦截重复三元组(1062 Duplicate entry)',
@ -354,7 +395,7 @@ def phase_idempotent(collector, box):
'ON DUPLICATE KEY UPDATE' in src) 'ON DUPLICATE KEY UPDATE' in src)
check('IDM.8 dedup_key 与 uk 三元组同源(md5(tenant|event|type))', check('IDM.8 dedup_key 与 uk 三元组同源(md5(tenant|event|type))',
_dedup_matches(rows2)) _dedup_matches(rows2))
return rows2 return s2, rows2
def _dedup_matches(rows): def _dedup_matches(rows):
@ -390,36 +431,90 @@ def phase_third_run(collector, box, s2_stats):
def db_kwargs(): def db_kwargs():
env = os.environ.get('PBL_M5A_DB') """沙箱库连接参数。凭据唯一事实源(**代码内零明文口令**,取不到即 fail-fast):
if env:
host, port, user, pwd = env.split(':') 1. 环境变量 ``PBL_M5A_DB=host:port:user:pwd``(口令允许含冒号,按前 3 段切分)
return dict(host=host, port=int(port), user=user, password=pwd) 2. 环境文件 ``projects/pbls/env/test.json`` 的 ``db.sandbox`` 段
# 本机测试库凭据来自 projects/pbls/env/test.json 的 db 段(唯一事实源,未新增秘密) (``scope=sandbox_only``,仅授权一次性沙箱 schema 的建/删)
return dict(host='127.0.0.1', port=3306, user='test', password='test123',
charset='utf8mb4', connect_timeout=5) 两者都拿不到 → 抛 ``RuntimeError``(不回退任何默认账号、不猜凭据)。
"""
raw_env = (os.environ.get('PBL_M5A_DB') or '').strip()
if raw_env:
parts = raw_env.split(':', 3)
if len(parts) != 4:
raise RuntimeError(
'PBL_M5A_DB 格式必须为 host:port:user:pwd(4 段冒号分隔),'
'实际段数=%d' % len(parts))
host, port, user, pwd = parts
if not host or not user:
raise RuntimeError('PBL_M5A_DB 的 host/user 段为空,拒绝连接')
try:
port_i = int(port)
except ValueError:
raise RuntimeError('PBL_M5A_DB 的 port 段不是整数: %r' % (port,))
_secret_values.add(pwd)
_CRED_SOURCE[0] = '环境变量 PBL_M5A_DB'
return dict(host=host, port=port_i, user=user, password=pwd,
charset='utf8mb4', connect_timeout=5)
env_file = (os.environ.get('PBL_M5A_ENV_FILE') or '').strip() or ENV_FILE_DEFAULT
if not os.path.isfile(env_file):
raise RuntimeError(
'拿不到沙箱库凭据:环境变量 PBL_M5A_DB 未设置,且环境文件不存在: '
+ env_file
+ '。请设置 PBL_M5A_DB=host:port:user:pwd,或在该文件的 db.sandbox 段'
'(engine/host/port/user/password/charset/scope=sandbox_only)补齐沙箱账号。')
try:
with io.open(env_file, 'r', encoding='utf-8') as fh:
cfg = json.load(fh)
except ValueError as exc:
raise RuntimeError('环境文件不是合法 JSON: %s (%s)' % (env_file, _mask(exc)))
sandbox = ((cfg.get('db') or {}).get('sandbox')) or {}
if not sandbox:
raise RuntimeError(
'环境文件缺少 db.sandbox 段(沙箱库凭据唯一事实源): ' + env_file
+ ';或改用环境变量 PBL_M5A_DB=host:port:user:pwd')
scope = str(sandbox.get('scope') or '')
if scope and scope != 'sandbox_only':
raise RuntimeError(
'db.sandbox.scope=' + scope + ' 非 sandbox_only,拒绝用非沙箱账号跑 harness')
missing = [k for k in ('host', 'port', 'user') if sandbox.get(k) in (None, '')]
if 'password' not in sandbox:
missing.append('password')
if missing:
raise RuntimeError('db.sandbox 缺少必填字段: ' + ', '.join(missing)
+ '(文件 ' + env_file + ')')
pwd = str(sandbox['password'])
_secret_values.add(pwd)
_CRED_SOURCE[0] = '环境文件 ' + env_file + ' 的 db.sandbox 段'
return dict(host=str(sandbox['host']), port=int(sandbox['port']),
user=str(sandbox['user']), password=pwd,
charset=str(sandbox.get('charset') or 'utf8mb4'),
connect_timeout=5)
def main(): def run(args):
ap = argparse.ArgumentParser() """harness 主体(异常由 main() 统一收敛为显式 FAIL)。"""
ap.add_argument('--keep', action='store_true', help='跑完保留沙箱库')
args = ap.parse_args()
print('M5a live-DB harness | sandbox schema = %s | target = modules/pbl_evidence'
% SANDBOX_SCHEMA)
try: try:
import pymysql # noqa: F401 import pymysql # noqa: F401
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print('FAIL 环境缺少 pymysql,无法跑真实库幂等: %r' % (exc,)) check('ENV.1 运行环境具备 pymysql', False, repr(exc))
return 1 return 1
conn_kwargs = db_kwargs()
print('沙箱库凭据来源: %s | 目标 = %s@%s:%s(口令不打印,日志已脱敏)'
% (_CRED_SOURCE[0], conn_kwargs['user'], conn_kwargs['host'],
conn_kwargs['port']))
collector = load_collector() collector = load_collector()
box = Sandbox(db_kwargs()) box = Sandbox(conn_kwargs)
box.reset_schema() box.reset_schema()
box.open() box.open()
try: try:
install_stub(collector, box) install_stub(collector, box)
phase_fail_closed(collector, box) phase_fail_closed(collector, box)
_rows = phase_idempotent(collector, box) _s2, _rows = phase_idempotent(collector, box)
# 重跑一次统计对比(放在 phase 2 之后,避免影响其内部断言顺序) # 重跑一次统计对比(放在 phase 2 之后,避免影响其内部断言顺序)
import asyncio import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop() loop = asyncio.get_event_loop_policy().new_event_loop()
@ -436,9 +531,29 @@ def main():
print('沙箱库已清理: DROP DATABASE %s' % SANDBOX_SCHEMA) print('沙箱库已清理: DROP DATABASE %s' % SANDBOX_SCHEMA)
else: else:
print('--keep:沙箱库 %s 保留' % SANDBOX_SCHEMA) print('--keep:沙箱库 %s 保留' % SANDBOX_SCHEMA)
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--keep', action='store_true', help='跑完保留沙箱库')
args = ap.parse_args()
print('M5a live-DB harness | sandbox schema = %s | target = modules/pbl_evidence'
% SANDBOX_SCHEMA)
try:
run(args)
except SystemExit:
raise
except Exception as exc: # noqa: BLE001
# 未捕获异常(凭据缺失/连不上库/被测代码抛错)统一收敛为显式 FAIL 项,
# 打印异常类型 + 脱敏消息,绝不靠 traceback 截断证据。
check('HARNESS.1 harness 无未捕获异常(凭据可用 + 沙箱库可连 + 各阶段跑完)',
False, '%s: %s' % (type(exc).__name__, _mask(exc)))
print('未捕获异常汇总(脱敏): %s' % _mask(traceback.format_exc()))
if _failures: if _failures:
print('LIVE DB HARNESS FAILED (%d): %s' % (len(_failures), ' | '.join(_failures))) print('LIVE DB HARNESS FAILED (%d): %s'
% (len(_failures), ' | '.join(_mask(f) for f in _failures)))
return 1 return 1
print('LIVE DB HARNESS ALL PASS') print('LIVE DB HARNESS ALL PASS')
return 0 return 0