diff --git a/README.md b/README.md index 5516904..a63ccc9 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,34 @@ python3 scripts/load_path.py --check python3 scripts/selfcheck_m5a.py ``` +### 收口自证(git 收口前必跑,`scripts/verify_m5a_closure.py`) + +`selfcheck_m5a.py` 验的是「模块该长什么样」;`verify_m5a_closure.py` 验的是 +「**磁盘上的代码 == 门禁所声称的那份代码**」+ 工作空间卫生 + 文档/代码条数一致, +专防 QC 退回过的三类问题:① 交付件声称的新增门禁函数其实没落盘;② 以工作空间根的 +一次性补丁脚本(`tmp_patch_*.py`)充当交付件本体;③ README 条数与代码 CHECKS 漂移。 + +> 一条命令,7 项全通过输出 `CLOSURE VERIFY ALL PASS (7/7)` 且 exit 0: +> +> ```bash +> cd modules/pbl_evidence && python3 scripts/verify_m5a_closure.py +> ``` +> +> | 项 | 验什么 | +> |---|---| +> | V1 | `selfcheck_m5a.py` 的 4 个新增门禁函数(`check_harness_static` / `check_no_hardcoded_credentials` / `check_evidence_logs` / `check_harness_matches_model`)已落盘**且装配进 `CHECKS`**(防「定义了但不可达」死代码) | +> | V2 | `tests/m5a_live_db_harness.py` 崩溃版特征(`test123` / `args or {}` / `params or {}` / `dict(params or {})`)0 命中 + `ast.parse` 通过 | +> | V3 | `projects/{项目}/env/test.json` 的 `db.sandbox` 段齐备(`scope=sandbox_only` + `sandbox_schema` + host/port/user/password)——凭据唯一事实源在 env,代码内零明文 | +> | V4 | `docs/02-develop` 三份运行证据日志齐备,harness 日志含 C-3.1~C-3.4 全 `[PASS]` 与 IDM.6 的 `1062 Duplicate entry ... uk_ev_dedup` 真防重原文,且无裸 `Traceback` | +> | V5 | 工作空间根一次性产物卫生:`tmp_*.py` / `_scratch_*.py` / `*_patch.py` / `*_out.txt` / `*_ev.txt` / `r.txt` / `*.bak` / 根级临时目录全类扫描;M5a 自身转储必须已删,他链产物必须在 `WS_KEPT` 白名单里**逐项写明引用它的文档路径**,否则 FAIL | +> | V6 | README 声称的 selfcheck 门禁条数 == 代码 `CHECKS` 条数(12) | +> | V7 | README 本小节声称的收口条数 == 本脚本 `CHECKS` 条数(7),且脚本路径磁盘命中 | +> +> **复跑范围**:V1/V2/V6/V7 是模块内自证,任何宿主可复跑;V3/V4/V5 读工作空间级兄弟产物 +> (`projects/{项目}/...` 与工作空间根),仅在 pbls 项目工作空间内、或显式设置 +> `SDLC_WS_ROOT`(配合可选 `SDLC_PROJECT` 覆盖项目名,默认 `pbls`)时生效;定位不到工作空间 +> 根时这三项返回 `SKIPPED`(不计 FAIL),避免别的宿主挂载本模块时把「路径不存在」误判成质量回归。 + ## Integration(宿主挂载方式) 模块只依赖:基础包(sqlor、ahserver ServerEnv、appPublic 工具)、`pbl_common`(租户上下文/ diff --git a/scripts/verify_m5a_closure.py b/scripts/verify_m5a_closure.py index eeb253c..59776ba 100644 --- a/scripts/verify_m5a_closure.py +++ b/scripts/verify_m5a_closure.py @@ -19,8 +19,25 @@ # 全通过 → 逐条 [PASS] + `CLOSURE VERIFY ALL PASS (N/N)`,exit 0 # 任一失败 → [FAIL] + 原因,exit 1 -幂等契约:只读(不连库、不写文件、不取时间戳/随机数/绝对路径),所有明细 sorted() -后输出,重复执行逐字节一致。 +复跑范围与路径解析(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 @@ -34,10 +51,35 @@ 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') +# 项目名可由环境变量覆盖(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 = ( @@ -51,11 +93,62 @@ 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',) + +# ④ 工作空间根卫生门禁(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): @@ -64,10 +157,12 @@ def _read(path): def _rel(path): - try: - return os.path.relpath(path, WS_ROOT).replace(os.sep, '/') - except Exception: - return os.path.basename(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 装配(防「定义了但不可达」死代码) @@ -125,6 +220,8 @@ def verify_harness_clean(): # ── 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: @@ -152,11 +249,14 @@ def verify_env_sandbox(): 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))) @@ -198,24 +298,63 @@ def verify_evidence_logs(): return True, ' | '.join(detail) -# ── V5 工作空间卫生:QC #8 点名的一次性补丁已删,历史取证脚本按文档保留 +# ── V5 工作空间卫生(QC #2):全类一次性产物扫描 + 白名单必须写明引用文档 def verify_tmp_hygiene(): + if not WS_ROOT: + return True, 'SKIPPED: 未定位到机构工作空间根(%s),本项为工作空间级取证,跨宿主复跑不计分' % WS_ROOT_SRC 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)) + 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('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)) + 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) @@ -251,25 +390,55 @@ def verify_readme_matches_code(): 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), - ('工作空间根一次性补丁脚本已清理(QC #8)', verify_tmp_hygiene), + ('工作空间根一次性产物卫生门禁(全类模式扫描 + 白名单须写明引用文档)', 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) - print('[%s] V%d. %s' % ('PASS' if ok else 'FAIL', i, name)) + # 跨宿主复跑时工作空间级取证项返回 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)