refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档, sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。 迁移(来自 sap-cli,共 104 文件): - tests/ 692 例测试(15 个文件的内联 sys.path 改指 assets/) - openspec/ SDD 规格与归档变更(42 文件) - docs/ 开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md) - .claude/ rules 副本 + settings.json(供 Claude Code) - .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md - scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁) 修复(迁移暴露的真实缺陷): - assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`, 该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。 实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令 - pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本 - pyproject urls 改指 sap-cli-skill 机制调整: - .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行, 本仓在 Gitee 不会自动跑 - pre-commit 增本地测试门禁(Gitee 上真正生效的那道) - .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志) - 大文件上限 100KB→1MB(架构图 512KB) 守卫测试 tests/unit/test_repo_guards.py(10 → 18 例): - SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5 - references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移) - VERSION == sapcli.__version__ == README 版本 - 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮) 698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。 docs/RELEASING.md 重写为单源开发流程。
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""仓库不变量守卫(唯一源)。
|
||||
|
||||
本仓 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()
|
||||
Reference in New Issue
Block a user