"""Transplant preparation checks for #244 -> v0.3 / v0.4 / v0.5. What this pins (see TRANSPLANT-PREP-244.md next to the skill): - tools/check_transplant.py applies the graded transplant to fixture texts: v03 drops the G7 / full-auto sentences, v04 drops the G7 sentences and the tighten-3 pre-rebuild confirmation paragraph but keeps the full-auto sentences, v05 drops the whole QA section, its checklist item and the confirmation paragraph, and renumbers the appendix chapter to 11-auditor; - check-transplanted passes on those drafts and fails when an artifact reference survives or a preserved marker is dropped; - apply fails loudly when a required anchor is missing; - the real pack in this tree carries zero templates/artifacts/ reference lines after the user-signed v5 deletion, so the decision-B inventory cannot drift silently. Fixture texts are built from the script's own fragment constants, so the tests exercise the real redaction strings, not copies of them. The real-branch runs (against #199 / #205 / #207 / #244 texts) are recorded in the verification summary; they cannot run in CI because those branches are not in this tree. """ import importlib.util import subprocess import sys import tempfile import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] SKILL_DIR = (REPO_ROOT / "cloud" / "skills" / "software-engineering") SCRIPT = SKILL_DIR / "tools" / "check_transplant.py" SKILL_MD = SKILL_DIR / "SKILL.md" def _load_script(): spec = importlib.util.spec_from_file_location( "check_transplant", str(SCRIPT)) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module CT = _load_script() def _source_fixture(): m = CT parts = [ "---\nname: software-engineering\n---\n", "## 角色线程矩阵\n", "| 角色 | 线程数 | 负责目录 | 产出 |", "| QA / 测试 | 1 | `tests/` | 测试用例 |", "| 独立审计 | 1 | 只写审计记录 | 审计结论 |", "| 验收 / 发布 | 协调员兼任 | `docs/release/` | 验收清单 |", "## 失败、续跑与成本\n", "- 成本控制:分阶段本身就是成本控制。", "## 独立审计与自动推回\n", "本节是审计加固第 3 步的技能机制,与代码 `cloud/audit_pushback.py`", "(推回决策纯函数)双轨落地,只写文档不算落地。只补审计机制,", "不改上面已有规则的语义:G5 缺陷回路、合并拆分、G6 用户验收", "的原意都不变。" + m.AUDIT_WARNING, "### 独立审计角色:不写代码,只独立重跑、抽查证据、单独签字\n", "- 审计线程不写业务代码、不改待办、不给合并决定、不执行合并。", "### 自动推回:审计不过关,系统自动推回,不等人工点头\n", "- 推回上限与升级:推回修复最多三轮" + m.PUSHBACK_LIMIT_PAREN + "。", "- 与已有回路的关系:G5 缺陷回路复用为修复执行通道,推回派生的修复子任务", " 就按 G5 方式执行,只补“自动触发 + 合并发布阻塞”这两块缺失;", m.G7_FULLAUTO_BLOCK, "## 推倒重做:超过三次后的升级分支(不是并行回路)\n", "- 与上一节自动推回的关系再写一遍以免混淆:自动推回(1–3 轮修补)", " 在前、推倒重做(超过 3 次)在后,是同一链路的前后两段;", " 与 G5 缺陷回路的关系是复用修复执行通道(重做任务本身仍按", " 阶段派生、仍走 G5 回归复核)" + m.REBUILD_TAIL, "## 三条收紧:分流、保留资产、审计的审计\n", "- 收紧一:阈值不能死板,第三次失败先判根因。", m.TIGHTEN3_NOTIFY, "## QA 全链路:测试环节跑通各个和所有业务链路\n", "本节是“独立审计与 QA”部分的补充验收标准,与自动推回、推倒重做、", "三条收紧并列,不覆盖已有规则。" + m.QA_INTRO, "### 每个客户端交付前自测:三项缺一不算自测完\n", "- 单元测试 + 对照契约的集成测试 + 手工冒烟清单,三项缺一不算自测完。", "## 诚实边界:原生客户端\n", "- 在只有 Linux 主机的环境里,真原生 iOS 需要 Xcode。", "## 校验(协调员每过一个门禁自查一次)\n", "- 分阶段走了吗?G3.5 没过就开了 G4b / G4c 吗?", "- 审计与推回走了吗?审计签字人是否独立于执行人、协调员批准人、", " QA 测试人?安全签字是不是无条件通过?审计 fail 后有没有自动推回", " 并阻塞合并/发布?模块失败超过 3 次后有没有按推倒重做分支处理", " (先判根因、只推倒该模块、旧实现归档、避坑清单带进新包),", " 而不是无限修补或悄悄放行?", "- QA 全链路走了吗?每端自测三项齐了吗?PRD 验收标准全有对应用例吗?", " agent 专属六项齐了吗?跨端矩阵覆盖全部已选端了吗,未测端写明原因了吗?", " 未编译端是不是只写了“代码评审 + Web 核心验证”?QA 矩阵签字与", " 独立审计签字两个签字位都在且不是同一人吗?推回包里失败链路齐了吗,", " 重审重跑失败链路了吗?", "有一项为否,就停在当前门禁,不进入下一阶段。", "## 附录:角色模板(播种随附版)\n", "下面是 `templates/roles/` 的随附全文。", "---", "## 附录角色:08-shared-core(来源 templates/roles/08-shared-core.md)\n", "# 角色模板:共享核心\n", "- 不在共享逻辑里写某端专属代码。", "---", "## 附录角色:09-auditor(独立审计,新增)\n", "# 角色模板:独立审计(1 个线程)\n", "你是本项目的独立审计线程。", ] return "\n".join(parts) + "\n" def _target_fixture(target_id): markers = " ".join(CT.MARKERS[target_id]) last = ("## 附录角色:10-iteration(来源 templates/roles/10-iteration.md,v0.5 新增)" if target_id == "v05" else "## 附录角色:08-shared-core(来源 templates/roles/08-shared-core.md)") parts = [ "# software-engineering:多客户端软件工程编排\n", "## 角色线程矩阵\n", "| 角色 | 线程数 | 负责目录 | 产出 |", "| QA / 测试 | 1 | `tests/` | 测试用例 |", "| 验收 / 发布 | 协调员兼任 | `docs/release/` | 验收清单 |", "## 失败、续跑与成本\n", "- 成本控制:分阶段本身就是成本控制。", "## 诚实边界:原生客户端\n", "- 在只有 Linux 主机的环境里,真原生 iOS 需要 Xcode。", "## 校验(协调员每过一个门禁自查一次)\n", "- 分阶段走了吗?G3.5 没过就开了 G4b / G4c 吗?", "- 标记行:" + markers, "| G1 | PRD 冻结 | PRD 按 `templates/artifacts/prd-outline.md` 写完,用户故事有验收标准 | 回产品线程改 |", "问卷模板在 `templates/artifacts/intake-questions.md`。最关键的六问:", "- 验收清单模板在 `templates/artifacts/acceptance-checklist.md`。", "按 `templates/artifacts/prd-outline.md` 写一份 PRD,至少包含:", " 协议模板见 `templates/artifacts/` 下的 agent 任务契约模板。", "1. 按 `templates/artifacts/acceptance-checklist.md` 生成本项目的验收清单,", "有一项为否,就停在当前门禁,不进入下一阶段。", "## 附录:角色模板(播种随附版)\n", last + "\n", "# 角色模板:共享核心\n", "- 不在共享逻辑里写某端专属代码。", ] return "\n".join(parts) + "\n" def _run_script(testcase, args, tmp): src = Path(tmp.name) / "source.md" tgt = Path(tmp.name) / "target.md" src.write_text(_source_fixture(), encoding="utf-8") tgt.write_text(_target_fixture(args["target_id"]), encoding="utf-8") cmd = [sys.executable, str(SCRIPT), args["cmd"], "--source244", str(src), "--target", str(tgt), "--target-id", args["target_id"]] if args["cmd"] == "apply": out = Path(tmp.name) / "draft.md" cmd += ["--out", str(out)] if args["cmd"] == "check-transplanted": out = Path(tmp.name) / "draft.md" cmd += ["--transplanted", str(out)] result = subprocess.run(cmd, capture_output=True, text=True) return result, src, tgt, Path(tmp.name) / "draft.md" class TransplantPrepTestCase(unittest.TestCase): def test_apply_v03_drops_g7_and_fullauto(self): with tempfile.TemporaryDirectory() as tmp: tmp = type("T", (), {"name": tmp})() result, _s, _t, draft = _run_script( self, {"cmd": "apply", "target_id": "v03"}, tmp) self.assertEqual(result.returncode, 0, result.stderr) text = draft.read_text(encoding="utf-8") self.assertEqual(text.count("| 独立审计 |"), 1) self.assertNotIn("G7 热修复回路并行", text) self.assertNotIn("全自动 CI 自动修复", text) self.assertIn("验收失败导致的推倒要先通知用户", text) self.assertIn("## QA 全链路", text) self.assertNotIn("移植警告", text) self.assertNotIn("templates/artifacts", text) self.assertEqual( text.count("## 附录角色:09-auditor(来源 " "templates/roles/09-auditor.md)"), 1) check = subprocess.run( [sys.executable, str(SCRIPT), "check-transplanted", "--source244", str(_s), "--target", str(_t), "--target-id", "v03", "--transplanted", str(draft)], capture_output=True, text=True) self.assertEqual(check.returncode, 0, check.stderr) def test_apply_v04_drops_g7_and_notify_keeps_fullauto(self): with tempfile.TemporaryDirectory() as tmp: tmp = type("T", (), {"name": tmp})() result, _s, _t, draft = _run_script( self, {"cmd": "apply", "target_id": "v04"}, tmp) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("STOPPED", result.stderr) text = draft.read_text(encoding="utf-8") self.assertNotIn("G7 热修复回路并行", text) self.assertNotIn("验收失败导致的推倒要先通知用户", text) self.assertIn("与全自动 CI 自动修复限", text) self.assertIn("## QA 全链路", text) self.assertNotIn("templates/artifacts", text) check = subprocess.run( [sys.executable, str(SCRIPT), "check-transplanted", "--source244", str(_s), "--target", str(_t), "--target-id", "v04", "--transplanted", str(draft)], capture_output=True, text=True) self.assertEqual(check.returncode, 0, check.stderr) def test_apply_v05_drops_qa_and_notify_renumbers_11(self): with tempfile.TemporaryDirectory() as tmp: tmp = type("T", (), {"name": tmp})() result, _s, _t, draft = _run_script( self, {"cmd": "apply", "target_id": "v05"}, tmp) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("STOPPED", result.stderr) text = draft.read_text(encoding="utf-8") self.assertNotIn("## QA 全链路", text) self.assertNotIn("- QA 全链路走了吗?", text) self.assertNotIn("验收失败导致的推倒要先通知用户", text) self.assertEqual( text.count("## 附录角色:11-auditor(来源 " "templates/roles/11-auditor.md)"), 1) self.assertNotIn("09-auditor", text) self.assertNotIn("templates/artifacts", text) check = subprocess.run( [sys.executable, str(SCRIPT), "check-transplanted", "--source244", str(_s), "--target", str(_t), "--target-id", "v05", "--transplanted", str(draft)], capture_output=True, text=True) self.assertEqual(check.returncode, 0, check.stderr) def test_apply_missing_anchor_fails(self): with tempfile.TemporaryDirectory() as tmp: src = Path(tmp) / "source.md" tgt = Path(tmp) / "target.md" src.write_text(_source_fixture(), encoding="utf-8") tgt.write_text(_target_fixture("v03").replace( "| QA / 测试 |", "| QA |"), encoding="utf-8") result = subprocess.run( [sys.executable, str(SCRIPT), "apply", "--source244", str(src), "--target", str(tgt), "--target-id", "v03", "--out", str(Path(tmp) / "draft.md")], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) def test_check_rejects_surviving_artifact_ref(self): with tempfile.TemporaryDirectory() as tmp: tmp_o = type("T", (), {"name": tmp})() result, src, tgt, draft = _run_script( self, {"cmd": "apply", "target_id": "v03"}, tmp_o) self.assertEqual(result.returncode, 0, result.stderr) text = draft.read_text(encoding="utf-8") draft.write_text(text + "\n见 `templates/artifacts/x.md`。\n", encoding="utf-8") check = subprocess.run( [sys.executable, str(SCRIPT), "check-transplanted", "--source244", str(src), "--target", str(tgt), "--target-id", "v03", "--transplanted", str(draft)], capture_output=True, text=True) self.assertNotEqual(check.returncode, 0) self.assertIn("templates/artifacts", check.stderr) def test_check_rejects_dropped_marker(self): with tempfile.TemporaryDirectory() as tmp: tmp_o = type("T", (), {"name": tmp})() result, src, tgt, draft = _run_script( self, {"cmd": "apply", "target_id": "v03"}, tmp_o) self.assertEqual(result.returncode, 0, result.stderr) lines = draft.read_text(encoding="utf-8").split("\n") kept = [line for line in lines if not line.startswith("- 标记行:")] self.assertLess(len(kept), len(lines)) draft.write_text("\n".join(kept), encoding="utf-8") check = subprocess.run( [sys.executable, str(SCRIPT), "check-transplanted", "--source244", str(src), "--target", str(tgt), "--target-id", "v03", "--transplanted", str(draft)], capture_output=True, text=True) self.assertNotEqual(check.returncode, 0) def test_real_pack_artifact_inventory_is_zero_after_signed_deletion(self): # v5 用户定稿(2026-11-02):§4 的 6 处引用经用户签字删除, # 本测试由“钉住 6 处”改为“钉住 0 处”,防止删除后悄悄回潮。 text = SKILL_MD.read_text(encoding="utf-8") refs = [line for line in text.split("\n") if "templates/artifacts" in line] self.assertEqual(len(refs), 0, refs) self.assertIn("## 独立审计与自动推回", text) # 集成基底章节表:审计章在 v0.5 表里编号为 11-auditor #(00–08 + 09-ops + 10-iteration + 11-auditor), # #258 原生的 09-auditor 编号已按章节表重定基。 self.assertIn("## 附录角色:11-auditor(来源 " "templates/roles/11-auditor.md)", text) if __name__ == "__main__": unittest.main()