"""T5.5 Skill registry loader 单测(ARCH §5.6 加载段)。 `SkillRegistry` 从 `SkillRepo`(DB 行抽象)加载声明式 `AgentSpec`(builtin/custom/community), 提供 `get(name)` / `list_scope(scope)` / `names()`。加载时跑 `validate_declaration`—— 越权声明(reads/writes 指向未知表)的 skill 直接拒绝(不入册),守 §5.6 表权限契约。 纯内存 fake `SkillRepo`,无 DB:注册行只是声明数据,registry 转成 frozen AgentSpec。 不变量 #2:行里只带 tier,registry 不解析具体 model(model 解析在网关)。 """ from __future__ import annotations import pytest from ww_agents import AgentSpec from ww_shared import AppError, ErrorCode from ww_skills import SkillRecord, SkillRegistry def _record( name: str, *, scope: str = "custom", reads: tuple[str, ...] = (), writes: tuple[str, ...] = (), ) -> SkillRecord: return SkillRecord( name=name, scope=scope, tier="writer", system_prompt=f"prompt for {name}", reads=reads, writes=writes, genre=None, ) class _FakeSkillRepo: def __init__(self, records: list[SkillRecord]) -> None: self._records = records async def list_all(self) -> list[SkillRecord]: return list(self._records) @pytest.mark.asyncio async def test_load_builds_specs_keyed_by_name() -> None: repo = _FakeSkillRepo( [ _record( "worldgen", scope="builtin", reads=("world_entities",), writes=("world_entities",), ), _record("cpgen", scope="custom", reads=("characters",)), ] ) registry = await SkillRegistry.load(repo) assert set(registry.names()) == {"worldgen", "cpgen"} spec = registry.get("worldgen") assert isinstance(spec, AgentSpec) assert spec.tier == "writer" assert spec.reads == ("world_entities",) assert spec.scope == "builtin" @pytest.mark.asyncio async def test_get_missing_skill_raises_not_found() -> None: registry = await SkillRegistry.load(_FakeSkillRepo([_record("a")])) with pytest.raises(AppError) as exc: registry.get("missing") assert exc.value.code is ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_list_scope_filters_by_scope() -> None: repo = _FakeSkillRepo( [ _record("b1", scope="builtin"), _record("c1", scope="custom"), _record("c2", scope="custom"), ] ) registry = await SkillRegistry.load(repo) assert {s.name for s in registry.list_scope("custom")} == {"c1", "c2"} assert {s.name for s in registry.list_scope("builtin")} == {"b1"} @pytest.mark.asyncio async def test_load_rejects_over_permission_skill() -> None: # 越权声明(reads 指向未知表)→ 加载即拒绝(守 §5.6),不静默入册。 repo = _FakeSkillRepo([_record("evil", reads=("secret_table",))]) with pytest.raises(AppError) as exc: await SkillRegistry.load(repo) assert exc.value.code is ErrorCode.VALIDATION @pytest.mark.asyncio async def test_load_rejects_skill_colliding_with_reserved_review_name() -> None: # 用户 skill 偷用四审保留名(continuity)→ 入库即拒(守卫前移,不变量 #3)。 repo = _FakeSkillRepo([_record("continuity", reads=("chapter_digests",))]) with pytest.raises(AppError) as exc: await SkillRegistry.load(repo) assert exc.value.code is ErrorCode.VALIDATION @pytest.mark.asyncio async def test_load_rejects_skill_colliding_with_builtin_name() -> None: # 用户 skill 冒用非四审的内置 name(brainstorm)→ 入库即拒。 repo = _FakeSkillRepo([_record("brainstorm", reads=("projects",))]) with pytest.raises(AppError) as exc: await SkillRegistry.load(repo) assert exc.value.code is ErrorCode.VALIDATION @pytest.mark.asyncio async def test_resolver_build_does_not_raise_on_reserved_collision() -> None: # 守卫在入库期(load),resolver.build 纯合并不校验——不得因同名抛错。 from ww_skills import SpecResolver empty = await SkillRegistry.load(_FakeSkillRepo([])) resolver = SpecResolver.build(empty) # 不抛 assert resolver.get("continuity") is not None def test_skill_record_reads_writes_are_immutable_tuples() -> None: # frozen 只防整字段重绑,不防 list 原地变异——reads/writes 必须是 tuple(无 append)。 record = _record("immut", reads=("characters",), writes=("world_entities",)) assert isinstance(record.reads, tuple) assert isinstance(record.writes, tuple) assert record.reads == ("characters",) assert record.writes == ("world_entities",) assert not hasattr(record.reads, "append")