from __future__ import annotations from pathlib import PurePosixPath import pytest from pydantic import ValidationError from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.ingest.skill import SkillFilesBundle from personal_mcp.registry.load import _build_skill_record from personal_mcp.registry.models.registry import SkillSummaryRecord from tests.registry.models.test_document_validation import assert_model_is_frozen from tests.registry.models.test_document_validation import make_markdown_document from tests.registry.models.test_document_validation import make_skill_frontmatter_payload pytestmark = pytest.mark.unit def _make_skill_bundle( *, slug: str, frontmatter: str | None, reference_files: tuple[str, ...] = (), other_files: tuple[str, ...] = (), ) -> SkillFilesBundle: skill = make_markdown_document( f"skills/{slug}/SKILL.md", frontmatter=frontmatter, ) references = tuple(make_markdown_document(f"skills/{slug}/references/{filename}") for filename in reference_files) other = tuple(make_markdown_document(f"skills/{slug}/{filename}") for filename in other_files) return SkillFilesBundle(slug=slug, skill=skill, references=references, other=other) def _docs_index(bundle: SkillFilesBundle) -> dict[PurePosixPath, MarkdownDocument]: docs = {bundle.skill.relpath: bundle.skill} for doc in bundle.references: docs[doc.relpath] = doc for doc in bundle.other: docs[doc.relpath] = doc return docs class TestSkillValidationGates: """Gate-oriented validation coverage for skill conversion.""" class TestGate1LayoutValidation: """Gate 1: enforce required source shape before metadata parsing.""" def test_missing_frontmatter_fails_fast(self) -> None: """Ensures conversion rejects missing frontmatter at the layout gate.""" bundle = _make_skill_bundle(slug="alpha", frontmatter=None) with pytest.raises(ValueError, match="missing YAML frontmatter"): _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) class TestGate2MetadataValidation: """Gate 2: validate skill metadata via pydantic models.""" def test_rejects_non_semver_version(self) -> None: """Ensures semver violations fail during frontmatter model validation.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha", version="not-semver") bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) with pytest.raises(ValidationError, match="version must be semver"): _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) def test_rejects_reserved_name_tokens(self) -> None: """Ensures reserved words remain blocked for skill names.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha", name="claude-skill") bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) with pytest.raises(ValidationError, match="reserved words anthropic or claude"): _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) def test_rejects_missing_primary_capability(self) -> None: """Ensures canonical document capability is required.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha", capabilities=("resource://docs/index.md",)) bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) with pytest.raises(ValueError, match="capabilities must include"): _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) class TestGate3ResourceValidation: """Gate 3: resolve document and reference resources deterministically.""" def test_discovers_reference_from_filename(self) -> None: """Ensures top-level reference files are converted into reference records.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha") bundle = _make_skill_bundle( slug="alpha", frontmatter=frontmatter, reference_files=("quick-start.md",), ) record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) assert set(record.references) == {"quick-start"} assert record.references["quick-start"].uri == "resource://skills/alpha/references/quick-start" assert record.references["quick-start"].title == "Quick Start" def test_declared_reference_requires_existing_document(self) -> None: """Ensures declared reference paths must map to discovered markdown files.""" frontmatter = make_skill_frontmatter_payload( skill_id="alpha", references={ "guide": { "path": "references/guide.md", "title": "Guide", } }, ) bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) with pytest.raises(KeyError, match="reference document not found"): _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) class TestGate5ContractValidation: """Gate 5: validate model_dump contract shape for API surfaces.""" def test_skill_record_model_dump_contains_contract_fields(self) -> None: """Ensures skill record serialization contains expected resource contract keys.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha") bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) dumped = record.model_dump() assert dumped["skill_id"] == "alpha" assert dumped["document_uri"] == "resource://skills/alpha/document" assert "references" in dumped assert "document_content" in dumped def test_skill_summary_projection_stays_stable(self) -> None: """Ensures summary projection keeps only index-safe fields.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha") bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) summary = SkillSummaryRecord.from_record(record) assert summary.model_dump() == { "skill_id": "alpha", "name": "alpha", "description": "demo skill", "tags": ("testing",), "capabilities": ("resource://skills/alpha/document",), "document_uri": "resource://skills/alpha/document", "version": "1.0.0", } class TestGate6FreezeValidation: """Gate 6: ensure immutable runtime records.""" def test_skill_record_instance_is_frozen(self) -> None: """Ensures validated skill records cannot be mutated after creation.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha") bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter) record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) assert_model_is_frozen(record, attr="name", value="mutated") def test_reference_record_values_are_frozen(self) -> None: """Ensures nested reference records are immutable after publication.""" frontmatter = make_skill_frontmatter_payload(skill_id="alpha") bundle = _make_skill_bundle( slug="alpha", frontmatter=frontmatter, reference_files=("guide.md",), ) record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle)) assert_model_is_frozen(record.references["guide"], attr="title", value="Mutated")