"""仓库不变量守卫(唯一源)。 本仓 sap-cli-skill 自 2026-09-11 起是 sap-cli 工具与技能文档的**唯一来源** (原 sap-cli 源码仓已归档)。本测试守住几条「静默失效」类的仓库不变量: - `SKILL.md` 必须记录 parser 的**全部** CLI 命令(文档与代码不得脱节) - 开发铁律 1-5 必须是真实小节标题(不能只靠正文交叉引用) - 示例不得出现违反铁律 5 的 `--path ./src` 写法 - `references/` 三份规则齐备且含关键规则 - `VERSION` 与 `sapcli.__version__` 一致 历史背景:文档与代码曾双向脱节(SKILL.md 只写 9 个命令而 parser 有 31 个), 铁律 5 的目录规范也曾只写在文档里、工具却不支持。守卫把这类问题挡在提交前。 """ from __future__ import annotations import argparse import os import re import sys import unittest from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] ASSETS = PROJECT_ROOT / "assets" sys.path.insert(0, str(ASSETS)) SKILL_MD = PROJECT_ROOT / "SKILL.md" REFERENCES = PROJECT_ROOT / "references" VERSION_FILE = PROJECT_ROOT / "VERSION" # 开发铁律必须在 SKILL.md 里 IRON_RULE_MARKERS = ["⛔ 开发铁律", "铁律 1", "铁律 2", "铁律 3", "铁律 4", "铁律 5"] # 每份规则文件必须含的关键规则标记 RULE_CONTENT_MARKERS: dict[str, list[str]] = { "sap-tool-constraints.md": ["规则 7", "规则 8"], "abap-coding-rules.md": ["E04", "E05"], "error-handling.md": [], } class TestSkillDocStaysInSync(unittest.TestCase): """SKILL.md 是唯一源文档,必须与真实 CLI 保持一致。""" def _text(self) -> str: return SKILL_MD.read_text(encoding="utf-8") def test_skill_md_exists(self): self.assertTrue(SKILL_MD.is_file(), f"缺少 SKILL.md: {SKILL_MD}") def test_every_cli_command_is_documented(self): """parser 里的每个命令都必须在 SKILL.md 里有记录。""" from sapcli.cli.parser import build_parser parser = build_parser() subs = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] self.assertTrue(subs, "解析器未找到子命令") commands = sorted(subs[0].choices.keys()) text = self._text() undocumented = [ cmd for cmd in commands if not re.search(r"`" + re.escape(cmd) + r"(?=[ `/-])", text) ] self.assertFalse( undocumented, f"这些 CLI 命令在 SKILL.md 里没有记录(文档与代码脱节): {undocumented}", ) def test_iron_rules_are_real_headings(self): """铁律 1-5 必须是真实小节标题,不能只靠别处的交叉引用蒙过标记检查。 实测缺口:把「### 铁律 5」改成「### 铁律五」时,纯字符串标记检查仍 通过——因为正文多处出现「见铁律 5」。标题级断言才拦得住。 """ text = self._text() missing = [ n for n in range(1, 6) if not re.search(rf"^#{{2,4}}\s*铁律\s*{n}\b", text, re.MULTILINE) ] self.assertFalse(missing, f"SKILL.md 缺少这些铁律的小节标题: {missing}") def test_iron_rule_5_documents_tmp_layer(self): text = self._text() self.assertIn("铁律 5", text) self.assertIn("src/TMP", text, "铁律 5 必须写明本地开发包层固定为 TMP") def test_no_path_violating_iron_rule_5(self): """示例不得出现 `--path ./src`(铁律 5 要求指到对象类型目录)。 仅放行「规则说明本身」的行——它们用反例讲铁律 5,会同时出现 「不合格」或「平铺」字样。 """ violations = [] for line in self._text().splitlines(): if re.search(r"--path\s+\./src(?!/TMP)", line): if "不合格" not in line and "平铺" not in line: violations.append(line.strip()) self.assertFalse( violations, "SKILL.md 示例违反铁律 5(--path 必须指到 src/TMP/<对象类型>):\n " + "\n ".join(violations), ) class TestReferencesAreComplete(unittest.TestCase): """references/ 是规则的正本,必须齐备。""" def test_reference_files_exist(self): for name in RULE_CONTENT_MARKERS: self.assertTrue( (REFERENCES / name).is_file(), f"缺少规则文件: references/{name}", ) def test_rule_contents_keep_key_rules(self): for name, markers in RULE_CONTENT_MARKERS.items(): path = REFERENCES / name self.assertTrue(path.is_file(), f"缺少规则文件: {path}") text = path.read_text(encoding="utf-8") for marker in markers: self.assertIn(marker, text, f"{name} 缺少关键规则「{marker}」") class TestRepoLayout(unittest.TestCase): """唯一源仓库的关键结构必须存在(归档旧仓后这里就是唯一的家)。""" def test_ci_workflow_present(self): ci = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" self.assertTrue(ci.is_file(), f"缺少 CI 工作流: {ci}") def test_ci_targets_assets_layout(self): """CI 必须按 assets/ 布局安装与统计覆盖率(曾因 pyproject 在 assets/ 下踩坑)。""" text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") self.assertIn("./assets[dev]", text, "CI 未按 assets/ 布局安装依赖") self.assertIn("assets/sapcli/*", text, "CI 覆盖率统计路径未指向 assets/sapcli") def test_no_obsolete_pack_pipeline(self): """打包流程已废弃——存在即说明有回退或残留。""" self.assertFalse((PROJECT_ROOT / "scripts" / "pack_skill.py").exists(), "pack_skill.py 已废弃,不应出现在唯一源仓库") self.assertFalse((PROJECT_ROOT / "skill-src").exists(), "skill-src/ 已废弃(模板与 SKILL.md 会形成双源)") class TestRuleCopiesInSync(unittest.TestCase): """references/ 是规则正本,.claude/rules/ 是供 Claude Code 读的副本。 两处内容必须一致——同一份规则存两份迟早漂移(本仓已因双份来源吃过亏)。 改规则时两处都要改,只改一处这里会红。 """ def test_claude_rules_match_references(self): claude_rules = PROJECT_ROOT / ".claude" / "rules" if not claude_rules.is_dir(): self.skipTest("本机无 .claude/rules/(可选目录)") shared = sorted(p.name for p in REFERENCES.glob("*.md")) self.assertTrue(shared, "references/ 下没有规则文件") for name in shared: rc = claude_rules / name if not rc.exists(): continue # 只比对两边都有的文件 self.assertEqual( rc.read_text(encoding="utf-8"), (REFERENCES / name).read_text(encoding="utf-8"), f"规则副本漂移: .claude/rules/{name} 与 references/{name} 内容不一致", ) def test_no_extra_claude_rule(self): """references/ 有的规则,.claude/rules/ 不应缺失(否则 Claude Code 读到旧规则集)。""" claude_rules = PROJECT_ROOT / ".claude" / "rules" if not claude_rules.is_dir(): self.skipTest("本机无 .claude/rules/") missing = [ p.name for p in REFERENCES.glob("*.md") if not (claude_rules / p.name).exists() ] self.assertFalse(missing, f".claude/rules/ 缺少这些规则副本: {missing}") class TestVersionConsistency(unittest.TestCase): """VERSION 文件与代码里的版本号必须一致(曾出现 2.3.0 vs 2.5.1 漂移)。""" def test_version_file_matches_package(self): import sapcli file_version = VERSION_FILE.read_text(encoding="utf-8").strip() self.assertEqual( file_version, sapcli.__version__, f"VERSION({file_version}) 与 sapcli.__version__({sapcli.__version__}) 不一致", ) def test_version_file_is_parseable(self): v = VERSION_FILE.read_text(encoding="utf-8").strip() self.assertRegex(v, r"^\d+\.\d+(\.\d+)?$", f"VERSION 格式异常: {v!r}") def test_readme_version_matches(self): """README 里写的版本号也要一致(README 是纯文本,最容易忘改)。""" import sapcli text = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") m = re.search(r"当前版本\*\*:\s*(\S+)", text) self.assertIsNotNone(m, "README 找不到「当前版本」行") self.assertEqual(m.group(1), sapcli.__version__, f"README 版本({m.group(1)}) 与包版本({sapcli.__version__}) 不一致") def test_pyproject_version_is_dynamic_from_package(self): """pyproject 的版本必须动态取自 sapcli.__version__,避免两处手改。""" text = (ASSETS / "pyproject.toml").read_text(encoding="utf-8") self.assertIn('version = {attr = "sapcli.__version__"}', text) if __name__ == "__main__": unittest.main()