SKILL.md claimed its appendix was spliced verbatim from templates/roles/ by a generation script (see CHANGELOG), but the pack contained none of the three. Add the nine role sources (mechanically split from the v0.2 appendix; SKILL.md untouched, zero semantic change), tools/check_appendix.py (verify + --emit, stdlib only, never edits SKILL.md), CHANGELOG.md, and test_skill_appendix_provenance.py (5 tests). Seeding unchanged: skill_seed copies SKILL.md only, so templates/tools/CHANGELOG never reach the tenant home.
118 lines
4.8 KiB
Python
118 lines
4.8 KiB
Python
"""Appendix provenance for the software-engineering skill.
|
||
|
||
Background (see cloud/skills/software-engineering/CHANGELOG.md):
|
||
SKILL.md claimed its appendix was "spliced verbatim from
|
||
templates/roles/ by a generation script, see CHANGELOG", but the pack
|
||
contained none of the three: no templates/, no CHANGELOG.md, no
|
||
script. The appendix was therefore unverifiable -- a drifted chapter
|
||
would pass CI silently.
|
||
|
||
This file pins the repair, and nothing else:
|
||
- tools/check_appendix.py passes on the real pack (all nine chapters
|
||
match their templates/roles/ sources, CHANGELOG present and
|
||
mentioning templates/roles/);
|
||
- the same script FAILS (nonzero exit) on a temp copy with a source
|
||
deleted, a source tampered, or an extra source added -- so a future
|
||
"source missing" or "appendix drifted" state cannot slip through;
|
||
- --emit output is exactly the nine headings plus the nine source
|
||
bodies, so the spliced form is itself derived from the sources.
|
||
|
||
Scope guard: these tests never edit SKILL.md or the sources. A
|
||
failure here is an editorial decision for the skill's own track
|
||
(edit the source, then re-issue the appendix), not a silent rewrite.
|
||
Seeding behaviour is pinned elsewhere (test_skill_seed_pinning.py):
|
||
templates/, tools/ and CHANGELOG.md never reach the tenant home,
|
||
because skill_seed copies SKILL.md only.
|
||
"""
|
||
|
||
import shutil
|
||
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_appendix.py"
|
||
|
||
EXPECTED_IDS = ["00-coordinator", "01-product-manager", "02-architect",
|
||
"03-designer", "04-client-developer", "05-backend",
|
||
"06-qa", "07-acceptance", "08-shared-core"]
|
||
|
||
|
||
def _run(skill_dir=None):
|
||
cmd = [sys.executable, str(SCRIPT)]
|
||
if skill_dir is not None:
|
||
cmd += ["--skill-dir", str(skill_dir)]
|
||
return subprocess.run(cmd, capture_output=True, text=True)
|
||
|
||
|
||
def _temp_skill_copy(testcase):
|
||
tmp = tempfile.TemporaryDirectory()
|
||
testcase.addCleanup(tmp.cleanup)
|
||
dest = Path(tmp.name) / "software-engineering"
|
||
shutil.copytree(SKILL_DIR, dest)
|
||
return dest
|
||
|
||
|
||
class AppendixProvenanceTestCase(unittest.TestCase):
|
||
def test_check_script_passes_on_real_pack(self):
|
||
self.assertTrue(SCRIPT.is_file(), "校验脚本缺失:%s" % SCRIPT)
|
||
self.assertTrue(
|
||
(SKILL_DIR / "CHANGELOG.md").is_file(),
|
||
"CHANGELOG.md 缺失,SKILL.md 的“见 CHANGELOG”仍是悬空引用")
|
||
result = _run()
|
||
self.assertEqual(
|
||
result.returncode, 0,
|
||
"校验脚本应在真实包上通过,stderr:%s" % result.stderr)
|
||
self.assertIn("9 chapters match", result.stdout)
|
||
|
||
def test_missing_source_fails(self):
|
||
skill = _temp_skill_copy(self)
|
||
(skill / "templates" / "roles" / "03-designer.md").unlink()
|
||
result = _run(skill)
|
||
self.assertNotEqual(
|
||
result.returncode, 0, "源缺失时校验必须失败退出非零")
|
||
self.assertIn("03-designer", result.stderr)
|
||
|
||
def test_tampered_source_fails(self):
|
||
skill = _temp_skill_copy(self)
|
||
target = skill / "templates" / "roles" / "06-qa.md"
|
||
text = target.read_text(encoding="utf-8")
|
||
target.write_text(text.replace("不把“没测”写成“通过”",
|
||
"不把“没测”写成“通过X”"),
|
||
encoding="utf-8")
|
||
result = _run(skill)
|
||
self.assertNotEqual(
|
||
result.returncode, 0, "源被改动时校验必须失败退出非零")
|
||
self.assertIn("06-qa", result.stderr)
|
||
|
||
def test_extra_source_fails(self):
|
||
skill = _temp_skill_copy(self)
|
||
(skill / "templates" / "roles" / "99-extra.md").write_text(
|
||
"# 角色模板:多余\n", encoding="utf-8")
|
||
result = _run(skill)
|
||
self.assertNotEqual(
|
||
result.returncode, 0, "多余源文件时校验必须失败退出非零")
|
||
self.assertIn("99-extra", result.stderr)
|
||
|
||
def test_emit_is_headings_plus_sources(self):
|
||
result = subprocess.run(
|
||
[sys.executable, str(SCRIPT), "--emit"],
|
||
capture_output=True, text=True)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
expected_parts = []
|
||
for cid in EXPECTED_IDS:
|
||
heading = ("## 附录角色:%s(来源 templates/roles/%s.md)"
|
||
% (cid, cid))
|
||
source = (SKILL_DIR / "templates" / "roles"
|
||
/ (cid + ".md")).read_text(encoding="utf-8")
|
||
expected_parts.append(heading + "\n\n" + source.rstrip("\n"))
|
||
self.assertEqual(result.stdout, "\n\n".join(expected_parts) + "\n",
|
||
"--emit 输出须恰为 9 章标题 + 9 份源正文")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|