新增能力: - remote-enable 子命令:设置函数模块处理类型为远程启用(bgRFC/RFC 执行体必需)。 实现要点均按实机验证:PUT fmodule:processingType="rfc"(枚举值仅小写 rfc 有效, remoteEnabled/remote/RFC 等均 400);lockHandle 走 query 参数(放 header 报 ParameterNotFound);PUT 后 GET 复核回显;DDIC 结构参数才可远程启用(类本地类型 报"针对 RFC 不允许使用类或接口的类型")。 - create --type functiongroup 透传 --package / --corr_nr(此前 package 被静默写成 $TMP, 导致函数组无法归目标包)。 修复: - activate 请求体补重复引用:ADT CLAS 激活端点对 objectReferences 只有 1 个引用时 返回 HTTP 200 + 空 body 且不执行激活;引用数 ≥2 才真正激活(同对象重复亦可)。 - scanner 剥离 ADT 文件后缀(xxx.clas.abap 解析错误)。 守卫测试: - tests/unit/test_bgrfc_support.py(17 例):函数组 package 透传、processingType 助手、 remote-enable 命令(含 lockHandle 走 query/小写 rfc/错误透传/复核失配判失败)、 函数 URI 组装不得用组名冒充模块名。 - test_activate.py 增加激活请求体引用数守卫;test_repo_guards.py 增加后缀剥离守卫。 测试:tests/unit 676 + tests/test_sapcli.py 69 = 745 全绿 (PYTHONPATH=assets python -m unittest discover -s tests/unit -t tests)
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""仓库不变量守卫(唯一源)。
|
||
|
||
本仓 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 TestScannerSupportsAdtFilenames(unittest.TestCase):
|
||
"""扫描器必须支持 ADT/abapGit 风格文件名(`<name>.clas.abap`)。
|
||
|
||
背景:项目仓命名规范(如 ABAP_Integrated《命名规范》§4)要求 ADT 风格文件名,
|
||
而扫描器曾只剥 `.abap`,把 `zcl_demo.clas.abap` 解析成对象名 `ZCL_DEMO.CLAS`——
|
||
对象名带伪后缀,`sync/refresh/diff --all` 全部落空。目录规范只写在文档、
|
||
工具却不支持的同类问题,用守卫挡住。
|
||
"""
|
||
|
||
def test_every_type_with_adt_suffix_maps_to_known_type(self):
|
||
from sapcli.scanner import ADT_TYPE_SUFFIX, TYPE_DIR_LOOKUP
|
||
|
||
known = set(TYPE_DIR_LOOKUP.values())
|
||
unknown = [t for t in ADT_TYPE_SUFFIX if t not in known]
|
||
self.assertFalse(unknown, f"ADT 后缀表引用了未注册的对象类型: {unknown}")
|
||
|
||
def test_adt_suffix_stripped_for_all_registered_types(self):
|
||
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
|
||
|
||
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
|
||
name = f"zobj_{obj_type.lower()}"
|
||
self.assertEqual(
|
||
_strip_adt_suffix(f"{name}.{suffix}", obj_type), name,
|
||
f"{obj_type} 的 ADT 后缀 .{suffix} 未被剥离",
|
||
)
|
||
|
||
def test_plain_filename_unaffected(self):
|
||
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
|
||
|
||
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
|
||
name = f"zobj_{obj_type.lower()}"
|
||
self.assertEqual(_strip_adt_suffix(name, obj_type), name)
|
||
# 其他类型的后缀不得被误剥
|
||
self.assertEqual(_strip_adt_suffix(f"{name}.{suffix}", "unknown_type"), f"{name}.{suffix}")
|
||
|
||
|
||
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()
|