diff --git a/README.md b/README.md index 455ea27..5516904 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ INSERT —— `permission.permcode` = 路径本身,`rolepermission` 用 `role. > **自检入口**:离线门禁自检脚本 `scripts/selfcheck_m5a.py` 已落盘(只读、不连库、 > 输出逐字节确定),一条命令覆盖「三处同步 / RBAC 清单 vs 磁盘 / 模型四段式 / -> 幂等键语义 / pyproject 依赖 / 种子 JSON / C-3 fail-closed / README 路径命中」8 项, -> 全通过输出 `ALL PASS (8/8 checks)` 且 exit 0: +> 幂等键语义 / pyproject 依赖 / 种子 JSON / C-3 fail-closed / README 路径命中」8 项 + 「harness 静态门禁 / harness 零明文凭据 / 运行证据日志 / harness DDL 与模型一致」4 项,共 12 项, +> 全通过输出 `ALL PASS (12/12 checks)` 且 exit 0: > > ```bash > cd modules/pbl_evidence && python3 scripts/selfcheck_m5a.py @@ -181,7 +181,7 @@ python3 scripts/check_crud_json.py # ④ RBAC 清单与 wwwroot 磁盘一致性核对(不连库) python3 scripts/load_path.py --check -# ⑤ M5a 交付门禁一键自检(8 项全 PASS 才算可交付;失败 exit 1 并逐条打印原因) +# ⑤ M5a 交付门禁一键自检(12 项全 PASS 才算可交付;失败 exit 1 并逐条打印原因) python3 scripts/selfcheck_m5a.py ``` @@ -253,7 +253,7 @@ pbl_evidence/ ├── init/data.json # 编码种子(Format B:3 组 appcodes + appcodes_kv 子项) ├── sql/pbl_evidence.sql # 建表 SQL 快照 ├── scripts/load_path.py # RBAC 登记(模块层,禁通配符)+ --check 一致性核对 -├── scripts/selfcheck_m5a.py # M5a 交付门禁自检(8 项,只读不连库,ALL PASS 收尾) +├── scripts/selfcheck_m5a.py # M5a 交付门禁自检(12 项,只读不连库,ALL PASS 收尾) ├── skill/SKILL.md # 面向 agent 的模块技能文档 └── pyproject.toml ``` diff --git a/scripts/verify_m5a_closure.py b/scripts/verify_m5a_closure.py new file mode 100644 index 0000000..eeb253c --- /dev/null +++ b/scripts/verify_m5a_closure.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M5a 收口自证脚本(QC #1/#7/#8 退回项的**仓库内正式**取证工具)。 + +为什么存在 +---------- +上一轮把「新代码是否真在磁盘、证据日志是否齐备、临时补丁是否已清理」这套自证逻辑 +写在工作空间根的 `tmp_patch_u7rb.py` 里,以一次性工具脚本充当 develop 交付件本体 +(QC #8)。一次性脚本不入库 = 下一个人无从复跑,自证结论无法复核。本脚本把同一套 +检查沉淀进模块仓库,与 `selfcheck_m5a.py` 互补: + +* `selfcheck_m5a.py` —— 交付**门禁**(12 项,验模块结构/契约/幂等键/证据日志口径) +* 本脚本 —— **收口自证**(验「磁盘代码 == 门禁所声称的那份代码」+ 工作空间卫生 + + README 与代码的门禁条数一致,防文档/代码漂移) + +用法 +---- + cd modules/pbl_evidence && python3 scripts/verify_m5a_closure.py + # 全通过 → 逐条 [PASS] + `CLOSURE VERIFY ALL PASS (N/N)`,exit 0 + # 任一失败 → [FAIL] + 原因,exit 1 + +幂等契约:只读(不连库、不写文件、不取时间戳/随机数/绝对路径),所有明细 sorted() +后输出,重复执行逐字节一致。 +""" + +import ast +import json +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SELFCHK = os.path.join(ROOT, 'scripts', 'selfcheck_m5a.py') +HARNESS = os.path.join(ROOT, 'tests', 'm5a_live_db_harness.py') +README = os.path.join(ROOT, 'README.md') + +# 机构工作空间根:modules/{模块}/scripts/ → up 3 级 +WS_ROOT = os.path.abspath(os.path.join(ROOT, os.pardir, os.pardir)) +DOCS = os.path.join(WS_ROOT, 'projects', 'pbls', 'docs', '02-develop') +ENV_TEST = os.path.join(WS_ROOT, 'projects', 'pbls', 'env', 'test.json') + +# ① selfcheck 必须定义的 4 个新增门禁函数(QC #1 点名的改法) +GATE_FUNCS = ( + 'check_harness_static', + 'check_no_hardcoded_credentials', + 'check_evidence_logs', + 'check_harness_matches_model', +) +# ② harness 里必须 0 命中的崩溃/吞参/明文凭据特征(QC #1/#2 点名的旧缺陷) +HARNESS_FORBIDDEN = ('test123', 'args or {}', 'params or {}', 'dict(params or {})') +# ③ 三份运行证据日志(与 selfcheck EVIDENCE_LOGS 同名单) +EVIDENCE_LOGS = ('m5a-live-db-harness.log', 'm5a-selfcheck.log', 'm5a-pytest-offline.log') +RC_MARKER = '# RC=0' +# ④ 一次性补丁脚本清理名单(QC #8 点名 + 本批 s1/s2 期间新产生的) +TMP_PATCHES = ('tmp_patch_u7rb.py',) +# 工作空间根允许保留的历史取证脚本(M11b-1c-B 文档明确「正式唯一取证脚本」, +# 且被 projects/pbls/docs/M11b-1c-*.md 逐条引用作复跑入口,删除会破坏历史验收可复现性) +TMP_KEPT = ('tmp_m11b1c_diff_check.py',) + + +def _read(path): + with open(path, 'r', encoding='utf-8') as fh: + return fh.read() + + +def _rel(path): + try: + return os.path.relpath(path, WS_ROOT).replace(os.sep, '/') + except Exception: + return os.path.basename(path) + + +# ── V1 selfcheck 四个门禁函数已落盘且被 CHECKS 装配(防「定义了但不可达」死代码) +def verify_gate_functions(): + if not os.path.isfile(SELFCHK): + return False, '缺少 %s' % _rel(SELFCHK) + src = _read(SELFCHK) + try: + tree = ast.parse(src, filename=SELFCHK) + except Exception as exc: + return False, 'selfcheck_m5a.py 语法错误: %s' % exc + defined = {n.name for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + problems, detail = [], [] + missing = sorted(f for f in GATE_FUNCS if f not in defined) + if missing: + problems.append('selfcheck 缺门禁函数: %s' % ', '.join(missing)) + else: + detail.append('4 个门禁函数已定义: %s' % ', '.join(sorted(GATE_FUNCS))) + # 装配检查:函数名必须出现在 CHECKS 列表里,否则是永远不执行的死代码 + m = re.search(r'^CHECKS\s*=\s*\[(.*?)^\]', src, re.S | re.M) + if not m: + problems.append('未找到 CHECKS 装配表') + else: + unwired = sorted(f for f in GATE_FUNCS + if not re.search(r'\b' + re.escape(f) + r'\b(?!\s*def)', m.group(1))) + if unwired: + problems.append('门禁函数未装配进 CHECKS(死代码): %s' % ', '.join(unwired)) + else: + detail.append('均已装配进 CHECKS 装配表') + if problems: + return False, '; '.join(problems) + return True, ' | '.join(detail) + + +# ── V2 harness 修复到位:崩溃版特征 0 命中 +def verify_harness_clean(): + if not os.path.isfile(HARNESS): + return False, '缺少 %s' % _rel(HARNESS) + src = _read(HARNESS) + hits = [] + for token in HARNESS_FORBIDDEN: + n = src.count(token) + if n: + hits.append('%s×%d' % (token, n)) + if hits: + return False, 'harness 仍含崩溃/吞参/明文凭据特征: %s' % ', '.join(sorted(hits)) + try: + ast.parse(src, filename=HARNESS) + except Exception as exc: + return False, 'harness 语法错误: %s' % exc + return True, ('%s 零命中 %s,且 ast.parse 通过(修复版已在磁盘)' + % (_rel(HARNESS), ' / '.join(HARNESS_FORBIDDEN))) + + +# ── V3 env/test.json 的 db.sandbox 段(凭据唯一事实源,代码内零明文) +def verify_env_sandbox(): + if not os.path.isfile(ENV_TEST): + return False, '缺少 %s' % _rel(ENV_TEST) + try: + conf = json.loads(_read(ENV_TEST)) + except Exception as exc: + return False, 'env/test.json 不是合法 JSON: %s' % exc + db = conf.get('db') or {} + sb = db.get('sandbox') + if not isinstance(sb, dict): + return False, 'env/test.json 缺 db.sandbox 段(harness 无凭据事实源)' + problems, detail = [], [] + if sb.get('scope') != 'sandbox_only': + problems.append('db.sandbox.scope != sandbox_only(会被误用于业务库)') + else: + detail.append('scope=sandbox_only') + if not sb.get('sandbox_schema'): + problems.append('db.sandbox 缺 sandbox_schema(沙箱库名未声明)') + else: + detail.append('sandbox_schema=%s' % sb['sandbox_schema']) + # 沙箱库由 harness 运行时 CREATE DATABASE / DROP DATABASE,故不预置 dbname; + # 连接必填四项 + 自建 schema 名,才是这段配置的真实语义。 + for key in ('host', 'port', 'user', 'password'): + if key not in sb: + problems.append('db.sandbox 缺连接字段 %s' % key) + if problems: + return False, '; '.join(problems) + detail.append('连接必填字段齐备(host/port/user/password),schema 由 harness 自建自删') + return True, ' | '.join(detail) + + +# ── V4 三份证据日志齐备,且 harness 日志含 C-3 fail-closed + 1062 真防重原文 +def verify_evidence_logs(): + if not os.path.isdir(DOCS): + return False, '缺少证据目录 %s' % _rel(DOCS) + top = sorted(f for f in os.listdir(DOCS) if os.path.isfile(os.path.join(DOCS, f))) + problems, detail = [], [] + absent = sorted(set(EVIDENCE_LOGS) - set(top)) + if absent: + problems.append('缺少证据日志: %s' % ', '.join(absent)) + else: + detail.append('三份日志齐备: %s' % ', '.join(EVIDENCE_LOGS)) + for name in EVIDENCE_LOGS: + if name not in top: + continue + text = _read(os.path.join(DOCS, name)) + if name == 'm5a-selfcheck.log': + n = len(re.findall(r'^\[(?:PASS|FAIL)\] \d+\.', text, re.M)) + if n != 12: + problems.append('m5a-selfcheck.log [PASS]/[FAIL] 行数 %d != 12' % n) + elif 'ALL PASS (12/12 checks)' not in text: + problems.append('m5a-selfcheck.log 结论行不是 ALL PASS (12/12 checks)') + else: + detail.append('自检日志 12 行齐备且结论为 ALL PASS (12/12 checks)') + elif RC_MARKER not in text: + problems.append('%s 缺 `%s` 收尾标记' % (name, RC_MARKER)) + if re.search(r'^Traceback \(most recent call last\)', text, re.M): + problems.append('%s 含裸 Traceback 崩溃现场' % name) + if not problems and 'm5a-live-db-harness.log' in top: + hlog = _read(os.path.join(DOCS, 'm5a-live-db-harness.log')) + need = ['C-3.1', 'C-3.2', 'C-3.3', 'C-3.4', 'IDM.6'] + miss = sorted(t for t in need + if not re.search(r'\[PASS\]\s*' + re.escape(t) + r'\b', hlog)) + if miss: + problems.append('harness 日志缺 [PASS] 行: %s' % ', '.join(miss)) + elif '1062' not in hlog or 'uk_ev_dedup' not in hlog: + problems.append('harness 日志缺 1062 Duplicate entry / uk_ev_dedup 真防重原文') + else: + detail.append('harness 日志含 C-3.1~C-3.4 全 [PASS] + IDM.6 1062(uk_ev_dedup) 原文') + if problems: + return False, '; '.join(problems) + return True, ' | '.join(detail) + + +# ── V5 工作空间卫生:QC #8 点名的一次性补丁已删,历史取证脚本按文档保留 +def verify_tmp_hygiene(): + problems, detail = [], [] + still = sorted(f for f in TMP_PATCHES if os.path.isfile(os.path.join(WS_ROOT, f))) + if still: + problems.append('一次性补丁脚本仍在磁盘: %s' % ', '.join(still)) + else: + detail.append('QC #8 点名脚本已删除: %s' % ', '.join(TMP_PATCHES)) + left = sorted(f for f in os.listdir(WS_ROOT) + if f.startswith('tmp_') and f.endswith('.py')) + unexpected = sorted(set(left) - set(TMP_KEPT)) + if unexpected: + problems.append('工作空间根仍有未清理的一次性补丁: %s' % ', '.join(unexpected)) + kept = sorted(set(left) & set(TMP_KEPT)) + if kept: + detail.append('按 M11b-1c-B 文档保留历史取证脚本: %s' % ', '.join(kept)) + if problems: + return False, '; '.join(problems) + return True, ' | '.join(detail) + + +# ── V6 README 门禁条数与代码实际条数一致(防文档/代码漂移) +def verify_readme_matches_code(): + problems, detail = [], [] + if not os.path.isfile(README): + return False, '缺少 README.md' + if not os.path.isfile(SELFCHK): + return False, '缺少 scripts/selfcheck_m5a.py' + rtxt = _read(README) + stxt = _read(SELFCHK) + try: + tree = ast.parse(stxt, filename=SELFCHK) + except Exception as exc: + return False, 'selfcheck 语法错误: %s' % exc + n_checks = None + for node in tree.body: + if isinstance(node, ast.Assign) and any( + getattr(t, 'id', None) == 'CHECKS' for t in node.targets): + if isinstance(node.value, ast.List): + n_checks = len(node.value.elts) + if n_checks is None: + return False, '无法从 selfcheck 解析 CHECKS 条数' + claimed = sorted(set(re.findall(r'ALL PASS \((\d+)/\d+ checks\)', rtxt))) + if claimed != [str(n_checks)]: + problems.append('README 声称的门禁条数 %s 与代码 CHECKS 条数 %d 不一致' + % (claimed, n_checks)) + else: + detail.append('README 门禁条数 = 代码 CHECKS 条数 = %d' % n_checks) + if problems: + return False, '; '.join(problems) + return True, ' | '.join(detail) + + +CHECKS = [ + ('selfcheck 四个新增门禁函数已落盘且装配进 CHECKS', verify_gate_functions), + ('harness 修复版在磁盘:test123/args or {} 等 0 命中 + ast 可解析', verify_harness_clean), + ('env/test.json db.sandbox 段齐备(scope=sandbox_only,凭据唯一事实源)', verify_env_sandbox), + ('三份运行证据日志齐备(C-3.1~C-3.4 + 1062 真防重原文 + # RC=0)', verify_evidence_logs), + ('工作空间根一次性补丁脚本已清理(QC #8)', verify_tmp_hygiene), + ('README 门禁条数与 selfcheck CHECKS 条数一致', verify_readme_matches_code), +] + + +def main(): + print('verify_m5a_closure: target = modules/pbl_evidence') + failed = [] + for i, (name, fn) in enumerate(CHECKS, 1): + try: + ok, detail = fn() + except Exception as exc: + ok, detail = False, '检查执行异常 %s: %s' % (type(exc).__name__, exc) + print('[%s] V%d. %s' % ('PASS' if ok else 'FAIL', i, name)) + print(' %s' % detail) + if not ok: + failed.append(name) + if failed: + print('CLOSURE VERIFY FAILED (%d/%d)' % (len(failed), len(CHECKS))) + for n in failed: + print(' - %s' % n) + return 1 + print('CLOSURE VERIFY ALL PASS (%d/%d)' % (len(CHECKS), len(CHECKS))) + return 0 + + +if __name__ == '__main__': + sys.exit(main())