173 lines
7.9 KiB
Python
173 lines
7.9 KiB
Python
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")
|