424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""技能与文档索引行为验证。
|
||
|
||
保护:技能目录合同(SKILL.md 完整、名称唯一且全中文、frontmatter 一致)
|
||
与活跃文档索引(目录.md 标准索引、链接可达)。
|
||
|
||
已实现子集(W02):目录与链接检查、技能目录基础合同。
|
||
依赖技能承接(W13+)、角色目录(W06)与旧库参数守卫(W03/W07)的用例
|
||
在对应包落地后逐例实现。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
仓库根 = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(仓库根 / "工具"))
|
||
from 维护索引 import 检查技能目录, 检查目录链接 # noqa: E402
|
||
|
||
|
||
def _写能力样本(根: Path, 相对: str, 身份: str, 名称: str) -> Path:
|
||
import yaml
|
||
|
||
文件 = 根 / 相对
|
||
文件.parent.mkdir(parents=True, exist_ok=True)
|
||
声明 = {
|
||
"id": 身份,
|
||
"name": 名称,
|
||
"category": "method",
|
||
"contract_version": 1,
|
||
"description": "合成方法",
|
||
}
|
||
文件.write_text(
|
||
"---\n" + yaml.safe_dump(声明, allow_unicode=True) + "---\n\n只提出有依据的规划方法。\n"
|
||
)
|
||
return 文件
|
||
|
||
|
||
def test_passing_project_is_independent_of_current_repository__a189e4(tmp_path, monkeypatch):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
根 = tmp_path / "独立项目"
|
||
文件 = _写能力样本(根, ".agent/skills/alpha/SKILL.md", "alpha", "合成方法")
|
||
monkeypatch.chdir(tmp_path)
|
||
目录, 资源 = 编译能力资源(根, [文件.relative_to(根).as_posix()], set())
|
||
assert set(目录["method"]) == {"alpha"}
|
||
assert len(资源) == 1 and 资源["能力/method/alpha.md"] == 文件.read_bytes()
|
||
|
||
|
||
def test_nested_skill_path_is_accepted_from_manifest__c9cb85(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
相对 = ".agent/skills/planning/alpha/SKILL.md"
|
||
文件 = _写能力样本(tmp_path, 相对, "alpha", "合成方法")
|
||
目录, 资源 = 编译能力资源(tmp_path, [相对], set())
|
||
assert 目录["method"]["alpha"]["resource_path"] == "能力/method/alpha.md"
|
||
assert 资源["能力/method/alpha.md"] == 文件.read_bytes()
|
||
|
||
|
||
def test_chinese_only_policy_rejects_english_manifest_name__c089ce(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
_写能力样本(tmp_path, "SKILL.md", "alpha", "alpha")
|
||
with pytest.raises(ValueError, match="中文名称"):
|
||
编译能力资源(tmp_path, ["SKILL.md"], set())
|
||
|
||
|
||
def test_chinese_only_policy_allows_new_native_chinese_skill__b72bcc(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
a = _写能力样本(tmp_path, "规划/规划作品/SKILL.md", "plan-work", "规划作品")
|
||
b = _写能力样本(tmp_path, "写作/新增技能/SKILL.md", "new-method", "新增技能")
|
||
目录, 资源 = 编译能力资源(tmp_path, [p.relative_to(tmp_path).as_posix() for p in (a, b)], set())
|
||
assert {v["name"] for v in 目录["method"].values()} == {"规划作品", "新增技能"}
|
||
assert len(资源) == 2
|
||
|
||
|
||
def test_non_normalized_skill_name_is_rejected__5b50cc(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
_写能力样本(tmp_path, "SKILL.md", "alpha", "e\u0301-name")
|
||
with pytest.raises(ValueError, match="中文名称"):
|
||
编译能力资源(tmp_path, ["SKILL.md"], set())
|
||
|
||
|
||
def test_skill_name_with_decorative_punctuation_is_rejected__984f56(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
_写能力样本(tmp_path, "SKILL.md", "story", "故事结构!")
|
||
with pytest.raises(ValueError, match="中文名称"):
|
||
编译能力资源(tmp_path, ["SKILL.md"], set())
|
||
|
||
|
||
def test_资源按稳定身份编译且移动不改变身份__a66001(tmp_path):
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
文件 = tmp_path / "初始.md"
|
||
文件.write_text(
|
||
"---\nid: writer\nname: 写手\ncategory: role\ncontract_version: 1\n"
|
||
"description: 合成职责\n---\n\n只执行固定任务。\n"
|
||
)
|
||
目录, 资源 = 编译能力资源(tmp_path, ["初始.md"], set())
|
||
assert 目录["role"]["writer"]["name"] == "写手"
|
||
assert 资源[目录["role"]["writer"]["resource_path"]] == 文件.read_bytes()
|
||
文件.rename(tmp_path / "迁移.md")
|
||
新目录, _ = 编译能力资源(tmp_path, ["迁移.md"], set())
|
||
assert 新目录["role"]["writer"]["sha256"] == 目录["role"]["writer"]["sha256"]
|
||
|
||
|
||
def test_技能只声明已实现命令且重复身份拒绝__a66002(tmp_path):
|
||
import pytest
|
||
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
文件 = tmp_path / "SKILL.md"
|
||
文件.write_text(
|
||
"---\nid: inspect-task\nname: 查看任务\ncategory: operation\ncontract_version: 1\n"
|
||
"description: 合成操作\ncommands: [read_task]\n---\n\n按授权读取任务。\n"
|
||
)
|
||
with pytest.raises(ValueError, match="命令"):
|
||
编译能力资源(tmp_path, ["SKILL.md"], set())
|
||
目录, _ = 编译能力资源(tmp_path, ["SKILL.md"], {"read_task"})
|
||
assert 目录["operation"]["inspect-task"]["commands"] == ["read_task"]
|
||
(tmp_path / "副本.md").write_bytes(文件.read_bytes())
|
||
with pytest.raises(ValueError, match="重复"):
|
||
编译能力资源(tmp_path, ["SKILL.md", "副本.md"], {"read_task"})
|
||
|
||
|
||
def test_运行按发布目录读取角色正文和命令__a66003():
|
||
import pytest
|
||
|
||
from muse.任务运行.接口 import 角色策略目录
|
||
from muse.共享.错误 import 资源缺失错误
|
||
from muse.资源加载 import 读取能力, 读取能力目录
|
||
|
||
目录 = 读取能力目录()
|
||
assert set(目录["role"]) == {"writer", "planner", "extractor", "detector", "judge"}
|
||
角色 = 读取能力("role", "writer", 预期哈希=目录["role"]["writer"]["sha256"])
|
||
assert 角色["name"] == "写手" and "生成阶段" in 角色["正文"]
|
||
assert not 角色["正文"].startswith("---")
|
||
策略 = 角色策略目录.从发布包()
|
||
assert 策略.读取角色提示("writer") == 角色["正文"]
|
||
assert len(策略.资源发布身份) == 64
|
||
操作 = 读取能力("operation", "inspect-task")
|
||
assert "control_task" in 操作["commands"]
|
||
with pytest.raises(资源缺失错误):
|
||
读取能力("role", "writer", 预期哈希="0" * 64)
|
||
|
||
|
||
def _建技能(
|
||
根: Path, 分组: str, 名称: str, *, front名: str | None = None, 正文: str | None = None
|
||
) -> Path:
|
||
目录 = 根 / ".agent" / "skills" / 分组 / 名称
|
||
目录.mkdir(parents=True, exist_ok=True)
|
||
if 正文 is None:
|
||
有效名 = front名 if front名 is not None else 名称
|
||
正文 = f"---\nname: {有效名}\ndescription: 合成技能说明\n---\n\n# {名称}\n\n方法正文。\n"
|
||
(目录 / "SKILL.md").write_text(正文, encoding="utf-8")
|
||
return 目录
|
||
|
||
|
||
def test_agent_content_directories_have_standard_indexes__b35be1() -> None:
|
||
"""一级内容目录可从根索引发现,且使用固定五列表头。"""
|
||
根 = 仓库根 / ".agent"
|
||
根索引 = (根 / "目录.md").read_text()
|
||
表头 = "| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |"
|
||
for 目录 in sorted(p for p in 根.iterdir() if p.is_dir()):
|
||
索引 = 目录 / "目录.md"
|
||
assert 索引.is_file(), f"缺少一级目录索引:{目录.name}"
|
||
assert 索引.read_text().startswith(表头), f"未使用固定五列表头:{索引}"
|
||
assert f"{目录.name}/目录.md" in 根索引, f"一级目录不可发现:{目录.name}"
|
||
|
||
|
||
def test_active_markdown_links_resolve__3734a6() -> None:
|
||
"""活跃执行知识(.agent 树)的 Markdown 相对链接可达。"""
|
||
问题: list[str] = []
|
||
import re
|
||
|
||
链接 = re.compile(r"\[[^\]]*\]\(([^)#\s]+)[)#]")
|
||
for 文件 in (仓库根 / ".agent").rglob("*.md"):
|
||
if "__pycache__" in 文件.parts:
|
||
continue
|
||
base = 文件.parent
|
||
for 序, 行 in enumerate(文件.read_text(encoding="utf-8").splitlines(), 1):
|
||
if 行.strip().startswith("|"):
|
||
continue # 目录表行由 检查目录链接 覆盖
|
||
for 地址 in 链接.findall(行):
|
||
if 地址.startswith(("http://", "https://", "mailto:")):
|
||
continue
|
||
if not (base / 地址).exists():
|
||
问题.append(f"{文件.relative_to(仓库根)}:{序} 链接不存在:{地址}")
|
||
assert 问题 == [], "\n".join(问题[:10])
|
||
|
||
|
||
def test_skill_directory_without_skill_file_fails__e8450b(tmp_path: Path) -> None:
|
||
"""技能目录缺少 SKILL.md 时被报告。"""
|
||
目录 = tmp_path / ".agent" / "skills" / "写作" / "无文件技能"
|
||
目录.mkdir(parents=True)
|
||
(目录 / "references.md").write_text("只有资料", encoding="utf-8")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("缺少 SKILL.md" in p for p in 问题), 问题
|
||
|
||
|
||
def test_empty_skill_file_is_reported__1d2b76(tmp_path: Path) -> None:
|
||
"""空 SKILL.md 被报告,不静默放行。"""
|
||
_建技能(tmp_path, "写作", "空技能", 正文="---\n")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("为空" in p for p in 问题), 问题
|
||
|
||
|
||
def test_frontmatter_name_must_match_skill_directory__703c7b(tmp_path: Path) -> None:
|
||
"""frontmatter name 必须与技能目录名一致。"""
|
||
_建技能(tmp_path, "写作", "真名技能", front名="别名技能")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("name 与目录不一致" in p for p in 问题), 问题
|
||
|
||
|
||
def test_duplicate_frontmatter_names_are_reported__eb80b5(tmp_path: Path) -> None:
|
||
"""同名技能(跨分组)被报告。"""
|
||
_建技能(tmp_path, "写作", "重复技能")
|
||
_建技能(tmp_path, "规划", "重复技能")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("重复" in p for p in 问题), 问题
|
||
|
||
|
||
def test_chinese_skill_name_is_accepted__de3efe(tmp_path: Path) -> None:
|
||
"""全中文技能名通过全部目录合同。"""
|
||
_建技能(tmp_path, "写作", "中文技能")
|
||
assert 检查技能目录(tmp_path) == []
|
||
|
||
|
||
def test_chinese_only_policy_rejects_english_skill_name__d5c460(tmp_path: Path) -> None:
|
||
"""英文技能名被中文唯一政策拒绝。"""
|
||
_建技能(tmp_path, "写作", "english-skill")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("全中文" in p for p in 问题), 问题
|
||
|
||
|
||
def test_non_normalized_skill_name_is_rejected__a87a41(tmp_path: Path) -> None:
|
||
"""非 NFC 规范形式的技能名被拒绝。"""
|
||
非规范 = "组合e\u0301音技能" # e + 组合变音符:NFC 会合成 é,与原名不同
|
||
assert unicodedata.normalize("NFC", 非规范) != 非规范
|
||
_建技能(tmp_path, "写作", 非规范)
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("NFC" in p for p in 问题), 问题
|
||
|
||
|
||
def test_skill_name_with_whitespace_is_rejected__b68ab9(tmp_path: Path) -> None:
|
||
"""含空白的技能名被拒绝。"""
|
||
_建技能(tmp_path, "写作", "带 空白技能")
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("含空白" in p for p in 问题), 问题
|
||
|
||
|
||
def test_missing_skills_directory_fails_closed__5acb68(tmp_path: Path) -> None:
|
||
"""技能根目录缺失时明确失败,不静默跳过。"""
|
||
问题 = 检查技能目录(tmp_path)
|
||
assert any("不存在" in p for p in 问题), 问题
|
||
|
||
|
||
def test_index_matches_disk_and_manifest__000974() -> None:
|
||
"""每个分组的 目录.md 索引与磁盘上的技能目录一一对应(不多不少)。"""
|
||
import re
|
||
|
||
技能根 = 仓库根 / ".agent" / "skills"
|
||
链接 = re.compile(r"\]\(([^/]+)/SKILL\.md\)")
|
||
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()):
|
||
目录文件 = 分组 / "目录.md"
|
||
assert 目录文件.is_file(), f"分组缺目录:{分组.name}"
|
||
索引名 = set(链接.findall(目录文件.read_text(encoding="utf-8")))
|
||
磁盘名 = {p.name for p in 分组.iterdir() if p.is_dir() and (p / "SKILL.md").is_file()}
|
||
assert 索引名 == 磁盘名, (
|
||
f"分组 {分组.name} 索引与磁盘不一致:"
|
||
f"索引多出 {sorted(索引名 - 磁盘名)},磁盘多出 {sorted(磁盘名 - 索引名)}"
|
||
)
|
||
|
||
|
||
def test_仓库当前树通过目录链接检查__b7c8d9() -> None:
|
||
"""当前仓库 目录.md 索引链接全部可达。"""
|
||
assert 检查目录链接(仓库根) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# W06 承接:能力目录与角色合同(原 SkillCatalogTest)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _角色字节(身份: str, 名称: str, 额外: str = "") -> bytes:
|
||
return (
|
||
f"---\nid: {身份}\nname: {名称}\ncategory: role\n"
|
||
f"contract_version: 1\ndescription: 合成角色\n{额外}---\n\n你是{名称},按合同执行。\n"
|
||
).encode()
|
||
|
||
|
||
def test_current_role_catalog_is_exact_and_host_neutral__569f09() -> None:
|
||
"""已发布角色目录恰好五类且宿主中立:frontmatter 不携带任何模型绑定。"""
|
||
from muse.资源加载 import 解析能力声明, 读取能力目录
|
||
|
||
目录 = 读取能力目录()["role"]
|
||
assert set(目录) == {"writer", "planner", "extractor", "detector", "judge"}
|
||
from muse.资源加载 import 打开资源
|
||
|
||
for 身份, 条目 in 目录.items():
|
||
声明, 正文 = 解析能力声明(打开资源(条目["resource_path"]))
|
||
assert 声明["id"] == 身份 and 声明["category"] == "role"
|
||
assert "model" not in 声明 and "provider" not in 声明, f"{身份} 携带静态模型绑定"
|
||
assert 正文.strip()
|
||
|
||
|
||
def test_role_catalog_ignores_directory_index__4da912(tmp_path: Path) -> None:
|
||
"""能力目录只收登记文件;目录索引不是能力,不进入发布包。"""
|
||
import sys as _sys
|
||
|
||
_sys.path.insert(0, str(仓库根 / "工具"))
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
角色 = ("writer", "planner", "extractor", "detector", "judge")
|
||
名称 = {
|
||
"writer": "写手",
|
||
"planner": "规划员",
|
||
"extractor": "知识抽取员",
|
||
"detector": "检测员",
|
||
"judge": "评委",
|
||
}
|
||
for r in 角色:
|
||
(tmp_path / "角色").mkdir(exist_ok=True)
|
||
(tmp_path / "角色" / f"{名称[r]}.md").write_bytes(_角色字节(r, 名称[r]))
|
||
(tmp_path / "角色" / "目录.md").write_text(
|
||
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |\n"
|
||
"|------|----------|----------|----------|----------|\n",
|
||
encoding="utf-8",
|
||
)
|
||
目录, 资源 = 编译能力资源(tmp_path, [f"角色/{名称[r]}.md" for r in 角色], set())
|
||
assert set(目录["role"]) == set(角色)
|
||
assert len(资源) == 5 # 索引文件未被扫描进包
|
||
|
||
|
||
def test_role_contract_is_central_and_complete__a74ee6() -> None:
|
||
"""角色合同与策略是唯一中心:五角色、固定模型策略与正文提示都齐备。"""
|
||
import yaml
|
||
|
||
from muse.任务运行.角色策略 import 角色策略目录
|
||
from muse.资源加载 import 读取能力目录
|
||
|
||
定义 = yaml.safe_load((仓库根 / "配置" / "角色策略.yaml").read_text(encoding="utf-8"))
|
||
策略 = 角色策略目录(定义, 角色资源=读取能力目录()["role"])
|
||
合同 = (仓库根 / "muse" / "sot" / "角色合同.md").read_text(encoding="utf-8")
|
||
for 角色, 中文名 in {
|
||
"writer": "网文写手",
|
||
"detector": "检测员",
|
||
"judge": "质量评委",
|
||
"extractor": "知识抽取员",
|
||
}.items():
|
||
assert 角色 in 合同 and 中文名 in 合同
|
||
for 身份 in ("writer", "planner", "judge"):
|
||
assert 定义["roles"][身份]["model_policy"] == "fixed"
|
||
for 别名, 正名 in 定义.get("aliases", {}).items():
|
||
assert 策略.读取角色提示(别名) == 策略.读取角色提示(正名)
|
||
|
||
|
||
def test_role_frontmatter_rejects_static_model_binding__7d0984() -> None:
|
||
"""角色 frontmatter 出现 model 等未登记字段直接拒绝。"""
|
||
from muse.资源加载 import 解析能力声明
|
||
|
||
with pytest.raises(ValueError, match="未登记字段"):
|
||
解析能力声明(_角色字节("writer", "写手", 额外="model: claude-opus\n"))
|
||
with pytest.raises(ValueError, match="未登记字段"):
|
||
解析能力声明(_角色字节("planner", "规划员", 额外="provider: primary\n"))
|
||
|
||
|
||
def test_current_catalog_is_valid__0af776() -> None:
|
||
"""发布目录内每个能力都能按登记哈希读取且声明与目录一致。"""
|
||
from muse.资源加载 import 读取能力, 读取能力目录
|
||
|
||
目录 = 读取能力目录()
|
||
总数 = sum(len(v) for v in 目录.values())
|
||
assert 总数 > 0
|
||
for 分类, 条目组 in 目录.items():
|
||
for 身份, 条目 in 条目组.items():
|
||
资源 = 读取能力(分类, 身份, 预期哈希=条目["sha256"])
|
||
assert 资源["name"] == 条目["name"]
|
||
assert 资源["正文"].strip()
|
||
|
||
|
||
def test_directory_and_name_must_match__29d9f3(tmp_path: Path) -> None:
|
||
"""能力身份由声明而非文件位置决定:入包路径从身份派生,声明与目录一致。"""
|
||
import sys as _sys
|
||
|
||
_sys.path.insert(0, str(仓库根 / "工具"))
|
||
from muse.资源加载 import 解析能力声明
|
||
from 构建资源包 import 编译能力资源
|
||
|
||
# 源文件名与中文名无关;入包后由身份定位,不再允许目录名充当身份。
|
||
(tmp_path / "旧位置").mkdir()
|
||
源 = tmp_path / "旧位置" / "legacy-plan.md"
|
||
源.write_bytes(_角色字节("writer", "写手"))
|
||
目录, 资源 = 编译能力资源(tmp_path, ["旧位置/legacy-plan.md"], set())
|
||
assert "能力/role/writer.md" in 资源
|
||
声明, _ = 解析能力声明(资源["能力/role/writer.md"])
|
||
assert (声明["id"], 声明["name"]) == ("writer", "写手")
|
||
assert 目录["role"]["writer"]["resource_path"] == "能力/role/writer.md"
|
||
|
||
|
||
def test_chinese_skill_name_is_accepted__16a67a() -> None:
|
||
"""中文名称的能力声明被接受并原样保留。"""
|
||
from muse.资源加载 import 解析能力声明
|
||
|
||
声明, 正文 = 解析能力声明(_角色字节("writer", "写手"))
|
||
assert 声明["name"] == "写手"
|
||
assert "按合同执行" in 正文
|
||
|
||
|
||
def test_unknown_frontmatter_key_is_rejected__432390() -> None:
|
||
"""未知 frontmatter 键被拒绝;合法键集内仍通过。"""
|
||
from muse.资源加载 import 解析能力声明
|
||
|
||
with pytest.raises(ValueError, match="未登记字段"):
|
||
解析能力声明(_角色字节("judge", "评委", 额外="temperature: 0.7\n"))
|
||
声明, _ = 解析能力声明(_角色字节("judge", "评委"))
|
||
assert set(声明) == {"id", "name", "category", "contract_version", "description"}
|