pbl_evidence/scripts/verify_m5a_closure.py
2026-09-22 20:56:31 +08:00

456 lines
24 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 收口自证脚本(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
复跑范围与路径解析(QC #3)
--------------------------
本脚本的 V3/V4/V5 三项要读**工作空间级**的兄弟产物(`projects/{项目}/env/test.json`、
`projects/{项目}/docs/02-develop/*.log`、工作空间根的一次性产物),这些不在模块仓库
内。解析优先级(**不再无条件硬推层级**):
1. 环境变量 `SDLC_WS_ROOT` —— 显式指定机构工作空间根(跨宿主复跑用这个)
2. 环境变量 `SDLC_PROJECT` —— 指定项目名(默认 `pbls`),只影响 `projects/{p}/` 段
3. 缺省:按 `modules/{模块}/scripts/ → up 3 级` 推导,并**校验推导结果确实是工作空间
根**(同时存在 `projects/` 与 `modules/` 两个子目录)。推不出来时 V3/V4/V5 返回
明确的 SKIP 语义(`SKIPPED: 非 pbls 工作空间/未设 SDLC_WS_ROOT`)而不是伪报 FAIL,
避免别的宿主挂载本模块时把「路径不存在」误判成质量回归。
结论:**本脚本的 V1/V2/V6/V7 是模块内自证(任何宿主可复跑);V3/V4/V5 是工作空间级
取证,仅在 pbls 项目工作空间内或显式设置 `SDLC_WS_ROOT` 时生效**。
幂等契约:只读(不连库、不写文件、不取时间戳/随机数),所有明细 sorted() 后输出,
同一工作状态下重复执行逐字节一致(`os.path.abspath` 仅用于定位,不进入输出文本;
输出里一律用相对工作空间根的 posix 路径)。
"""
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')
# 项目名可由环境变量覆盖(QC #3:不把 pbls 写死进常量)
PROJECT = os.environ.get('SDLC_PROJECT', 'pbls')
def _looks_like_ws_root(path):
"""工作空间根的机械判据:同时含 projects/ 与 modules/ 两个子目录。"""
return (os.path.isdir(os.path.join(path, 'projects'))
and os.path.isdir(os.path.join(path, 'modules')))
def resolve_ws_root():
"""返回 (ws_root 或 None, 解析来源说明)。环境变量优先,其次层级推导+校验。"""
env_root = os.environ.get('SDLC_WS_ROOT')
if env_root:
cand = os.path.abspath(env_root)
if _looks_like_ws_root(cand):
return cand, 'SDLC_WS_ROOT 显式指定'
return None, ('SDLC_WS_ROOT=%s 不像机构工作空间根(缺 projects/ 或 modules/)'
% env_root)
cand = os.path.abspath(os.path.join(ROOT, os.pardir, os.pardir))
if _looks_like_ws_root(cand):
return cand, '按 modules/{模块}/scripts → up 3 级推导并校验 projects/+modules/ 命中'
return None, ('无法定位机构工作空间根(推导结果 %s 缺 projects/ 或 modules/);'
'跨宿主复跑请设 SDLC_WS_ROOT' % cand)
WS_ROOT, WS_ROOT_SRC = resolve_ws_root()
DOCS = os.path.join(WS_ROOT, 'projects', PROJECT, 'docs', '02-develop') if WS_ROOT else None
ENV_TEST = os.path.join(WS_ROOT, 'projects', PROJECT, 'env', 'test.json') if WS_ROOT else None
# ① 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 #2 退回项:口径从「只匹配 tmp_*.py」扩到全类一次性产物)
#
# 一次性产物的**机械判据**(命中任一模式即视为需处置,不再只看 tmp_ 前缀):
TMP_PATTERNS = (
r'^tmp_.*\.py$', # 一次性补丁/取证脚本
r'^_scratch_.*\.py$', # 一次性补丁脚本(下划线前缀变体)
r'^.*_patch\.py$', # 名字里带 patch 的脚本
r'^.*_out\.txt$', # 运行转储
r'^.*_after\.txt$', # 改造前后对照转储
r'^.*_ev\d+\.txt$', # 取证转储分片
r'^.*evidence.*\.txt$', # 取证转储
r'^r\d+\.txt$', # run #N 复跑转储
r'^closure_out\.txt$', # 收口转储
r'^.*\.bak$', # 编辑备份件
)
# 根级临时**目录**判据:以 `.` 开头且不是 VCS/工具配置白名单,或名字含 scratch/tmp/work
WS_DIR_ALLOW = ('.git', '.pylibs', '.pytest_cache', '.cache', '.ssh', '.config',
'.venv', '.local', '.viminfo')
TMP_DIR_PATTERNS = (r'^\.[dqtms_]', r'^_scratch$', r'^tmp_tools$', r'.*scratch.*',
r'.*tmp.*', r'.*work.*')
# **白名单**:确需保留的历史取证产物,逐项写明「谁引用它」(文档路径),
# 未列入本表(且不属于他链保留表)的根级一次性产物一律判 FAIL。
# 保留理由:这些文件被 projects/pbls 仓内的验收文档逐条引用作「复跑入口」或「留证原件」,
# 删除会破坏**其他任务链**(M11b-1c / M11b-2d / M11b-4 / M9 / M1b)的历史验收可复现性;
# 本任务(M5a 收口)只删 M5a 链路自身产生的转储,不代处置他链产物。
WS_KEPT = {
'tmp_m11b1c_diff_check.py': 'M11b-1c 正式取证脚本,被 projects/pbls/docs/M11b-1c-A_revert_generator_overreach.md、projects/pbls/docs/02-develop/dev-notes-m11b1c-b.md、dev-notes-m11b1-pbl_runtime_event.md 引用为复跑入口',
'_scratch_m11b2d_a2_patch.py': 'M11b-2d-A2 补丁原件,被 projects/pbls/deliverables/agent.develop/OLdcikgd7ELN0ked7f33d_agent.develop_fallback.md 引用留证',
'_scratch/': 'M11b-2d 补丁脚本目录,同上(_scratch_m11b2d_a2_patch.py 的目录形态副本)',
'_m11b2d_a2_after.txt': 'M11b-2d-A2 改造后对照转储,被 projects/pbls/deliverables/agent.develop/OLdcikgd7ELN0ked7f33d_agent.develop_fallback.md 引用',
'm11b4_out.txt': 'M11b-4 自测转储,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 登记为「他链产物·保留原样」',
'm1b_evidence.txt': 'M1b 取证转储,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 登记为「他链产物·保留原样」',
'm1b_ev2.txt': 'M1b 取证转储分片,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 末段(清单明列 m1b_ev2..ev6.txt)登记为「他链产物·保留原样」',
'm1b_ev3.txt': 'M1b 取证转储分片,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 末段(清单明列 m1b_ev2..ev6.txt)登记为「他链产物·保留原样」',
'm1b_ev4.txt': 'M1b 取证转储分片,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 末段(清单明列 m1b_ev2..ev6.txt)登记为「他链产物·保留原样」',
'm1b_ev5.txt': 'M1b 取证转储分片,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 末段(清单明列 m1b_ev2..ev6.txt)登记为「他链产物·保留原样」',
'm1b_ev6.txt': 'M1b 取证转储分片,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 末段(清单明列 m1b_ev2..ev6.txt)登记为「他链产物·保留原样」',
'.dev-notes.bak': 'dev-notes.md 编辑备份,被 projects/pbls/docs/02-develop/m9-closureD-cleanup-record.md §4 判定「保留不动,仅作观察上报」',
'tmp_tools/': 'M11b-4a 取证脚本目录,projects/pbls 仓 commit e8c08f1 已记录其处置结论(保留待 M11b-4c 收口)',
'.qc_tmp/': 'QC 侧(agent.qc)工作目录,非 develop 产物,归属 QC 任务链处置',
'.qc_tools/': 'QC 侧工具目录,非 develop 产物,归属 QC 任务链处置',
'.dev_tmp/': 'M11b-2c-T2a 编辑中转目录,属 M11b-2c 任务链产物(该链收口任务运行中,不代处置)',
'.tmpdev/': 'M11b-2c-T2 round3 补丁脚本目录,属 M11b-2c 任务链产物(该链收口任务运行中,不代处置)',
'.m11b2work/': 'M11b-2 运行转储目录,属 M11b-2 任务链产物',
'.scratch/': 'M10-a 桩目录(含 verify_m10a.py),属 M10-a 任务链产物(该链任务运行中)',
'.m10a_verify/': 'M10-a 验证中转目录(stub + verify.py),属并行运行中的 M10-a 任务链(task NBrM2qPSIMSbtMH9lM6Lu)产物,本任务不代处置',
'.tmpb/': 'M11b-4 v4 汇总草稿目录,属 M11b-4 任务链产物',
'.d/': '空目录(编辑器/工具误建),无内容、无证据价值,本任务已删除;若复跑再出现则判 FAIL',
}
# M5a 链路自身产生的转储(本任务必须删除,不允许进白名单)——列出来作为「反向门禁」:
# 只要它们还在磁盘上就 FAIL,删掉后自然不再命中模式检查。
M5A_OWN_DUMP_GONE = ('evidence_raw.txt', 'r1.txt', 'r2.txt', 'closure_out.txt',
'.scratch_m5a/')
def _read(path):
with open(path, 'r', encoding='utf-8') as fh:
return fh.read()
def _rel(path):
if path and WS_ROOT:
try:
return os.path.relpath(path, WS_ROOT).replace(os.sep, '/')
except Exception:
pass
return os.path.basename(path or '')
# ── 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 WS_ROOT:
return True, 'SKIPPED: 未定位到机构工作空间根(%s),本项为工作空间级取证,跨宿主复跑不计分' % WS_ROOT_SRC
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 自建自删')
detail.append('路径来源: %s / 项目名=%s' % (WS_ROOT_SRC, PROJECT))
return True, ' | '.join(detail)
# ── V4 三份证据日志齐备,且 harness 日志含 C-3 fail-closed + 1062 真防重原文
def verify_evidence_logs():
if not WS_ROOT:
return True, 'SKIPPED: 未定位到机构工作空间根(%s),本项为工作空间级取证,跨宿主复跑不计分' % WS_ROOT_SRC
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 #2):全类一次性产物扫描 + 白名单必须写明引用文档
def verify_tmp_hygiene():
if not WS_ROOT:
return True, 'SKIPPED: 未定位到机构工作空间根(%s),本项为工作空间级取证,跨宿主复跑不计分' % WS_ROOT_SRC
problems, detail = [], []
entries = sorted(os.listdir(WS_ROOT))
files = [e for e in entries if os.path.isfile(os.path.join(WS_ROOT, e))]
dirs = [e for e in entries if os.path.isdir(os.path.join(WS_ROOT, e))]
# 5.1 M5a 链路自身转储必须已删除(反向门禁:出现即 FAIL,不许进白名单)
own_left = sorted(p for p in M5A_OWN_DUMP_GONE
if (p in files) or (p in dirs))
if own_left:
problems.append('M5a 链路自身产生的一次性转储仍未删除: %s' % ', '.join(own_left))
else:
detail.append('M5a 自身转储已全部删除: %s' % ', '.join(M5A_OWN_DUMP_GONE))
# 5.2 文件类一次性产物:命中模式 → 必须在白名单(且白名单写明引用文档)
hit_files, kept_files = [], []
for f in files:
if any(re.match(p, f) for p in TMP_PATTERNS):
(kept_files if f in WS_KEPT else hit_files).append(f)
if hit_files:
problems.append('工作空间根存在未登记的一次性产物(须删除或进 WS_KEPT 并写明引用文档): %s'
% ', '.join(sorted(hit_files)))
if kept_files:
no_doc = sorted(k for k in kept_files if 'projects/pbls' not in WS_KEPT[k])
if no_doc:
problems.append('白名单项缺「引用它的文档路径」说明: %s' % ', '.join(no_doc))
else:
detail.append('按他链文档保留 %d 项(逐项已写明引用文档): %s'
% (len(kept_files), ', '.join(sorted(kept_files))))
# 5.3 目录类一次性产物:命中模式 → 必须在白名单
hit_dirs, kept_dirs = [], []
for d in dirs:
if d in WS_DIR_ALLOW:
continue
if any(re.match(p, d) for p in TMP_DIR_PATTERNS):
(kept_dirs if d + '/' in WS_KEPT else hit_dirs).append(d)
if hit_dirs:
problems.append('工作空间根存在未登记的临时目录(须删除或进 WS_KEPT 并写明归属任务链): %s'
% ', '.join(sorted(hit_dirs)))
if kept_dirs:
detail.append('他链临时目录按归属保留: %s' % ', '.join(sorted(kept_dirs)))
# 5.4 空目录一律不许留在根上(无证据价值,且会让下一次扫描出现「幽灵项」)
empty = sorted(d for d in dirs if d not in WS_DIR_ALLOW
and not os.listdir(os.path.join(WS_ROOT, d)))
if empty:
problems.append('工作空间根有空目录: %s' % ', '.join(empty))
else:
detail.append('根目录无空目录残留')
if problems:
return False, '; '.join(problems)
detail.append('路径来源: %s' % WS_ROOT_SRC)
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)
# ── V7 README 对本收口脚本的描述与代码一致(收口脚本自身也不许「文档漂移」)
def verify_readme_matches_closure():
if not os.path.isfile(README):
return False, '缺少 README.md'
rtxt = _read(README)
if 'verify_m5a_closure.py' not in rtxt:
return False, 'README 未引用 scripts/verify_m5a_closure.py(本脚本成为无人知晓的孤儿件)'
# 取 README 中提及本脚本的那个小节(从该标题行到下一个二级标题)
lines = rtxt.splitlines()
start = next((i for i, ln in enumerate(lines) if 'verify_m5a_closure.py' in ln), None)
if start is None:
return False, 'README 未提及本脚本'
end = start
while end < len(lines) and not (end > start and lines[end].startswith('## ')):
end += 1
section = '\n'.join(lines[start:end])
claimed = sorted(set(re.findall(r'CLOSURE VERIFY ALL PASS \((\d+)/\d+\)', section)))
n = len(CHECKS)
if claimed != [str(n)]:
return False, ('README 收口小节声称 %s 与代码 CHECKS 条数 %d 不一致'
% (claimed or '无条数声明', n))
return True, 'README 收口小节声称条数 = 代码 CHECKS 条数 = %d,且脚本路径磁盘命中' % n
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),
('工作空间根一次性产物卫生门禁(全类模式扫描 + 白名单须写明引用文档)', verify_tmp_hygiene),
('README 门禁条数与 selfcheck CHECKS 条数一致', verify_readme_matches_code),
('README 收口小节条数与本脚本 CHECKS 条数一致(防收口件文档漂移)', verify_readme_matches_closure),
]
def main():
print('verify_m5a_closure: target = modules/pbl_evidence')
print('workspace root = %s (%s); project = %s'
% (WS_ROOT or 'UNRESOLVED', WS_ROOT_SRC, PROJECT))
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)
# 跨宿主复跑时工作空间级取证项返回 SKIPPED(不计分也不冒充 PASS 结论)
skipped = ok and detail.startswith('SKIPPED')
print('[%s] V%d. %s' % ('SKIP' if skipped else ('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())