295 lines
16 KiB
Python
295 lines
16 KiB
Python
"""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 (v0.2 + #244 line) still carries exactly
|
||
the six known templates/artifacts/ reference lines, 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 = [l for l in lines if not l.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_six_lines(self):
|
||
text = SKILL_MD.read_text(encoding="utf-8")
|
||
refs = [l for l in text.split("\n") if "templates/artifacts" in l]
|
||
self.assertEqual(len(refs), 6, 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()
|