#!/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())