456 lines
24 KiB
Python
456 lines
24 KiB
Python
#!/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())
|