from __future__ import annotations from pathlib import PurePosixPath from typing import Any import pytest import yaml from pydantic import ValidationError from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.models.common import ReferenceEntry from personal_mcp.registry.models.common import parse_docs_path from personal_mcp.registry.models.common import parse_reference_path from personal_mcp.registry.models.registry import DocsRegistry pytestmark = pytest.mark.unit def make_markdown_document( relpath: str, *, content: str = "# body\n", frontmatter: str | None = None, ) -> MarkdownDocument: """Builds a markdown ingest document with deterministic defaults.""" return MarkdownDocument( relpath=PurePosixPath(relpath), content=content, frontmatter=frontmatter, ) def make_skill_frontmatter_payload( *, skill_id: str, name: str | None = None, version: str = "1.0.0", description: str = "demo skill", tags: tuple[str, ...] = ("testing",), capabilities: tuple[str, ...] | None = None, references: dict[str, dict[str, Any]] | None = None, ) -> str: """Builds frontmatter payload YAML for skill conversion tests.""" canonical_name = name or skill_id x_personal_mcp: dict[str, Any] = { "id": skill_id, "version": version, "tags": list(tags), "capabilities": list(capabilities or (f"resource://skills/{canonical_name}/document",)), } if references is not None: x_personal_mcp["references"] = references payload: dict[str, Any] = { "name": canonical_name, "description": description, "x-personal-mcp": x_personal_mcp, } return yaml.safe_dump(payload, sort_keys=False) def make_prompt_frontmatter_payload( *, prompt_id: str, name: str | None = None, version: str = "1.0.0", description: str = "demo prompt", tags: tuple[str, ...] = ("testing",), capabilities: tuple[str, ...] | None = None, arguments: dict[str, dict[str, Any]] | None = None, ) -> str: """Builds frontmatter payload YAML for prompt conversion tests.""" canonical_name = name or prompt_id payload: dict[str, Any] = { "name": canonical_name, "description": description, "x-personal-mcp": { "id": prompt_id, "version": version, "tags": list(tags), "capabilities": list(capabilities or (f"resource://prompts/{canonical_name}/document",)), "arguments": arguments or {}, }, } return yaml.safe_dump(payload, sort_keys=False) def as_markdown(frontmatter_yaml: str, *, body: str = "# body\n") -> str: """Wraps frontmatter YAML in markdown fence delimiters.""" return f"---\n{frontmatter_yaml.strip()}\n---\n{body}" def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None: """Asserts pydantic frozen model semantics.""" with pytest.raises(ValidationError, match="Instance is frozen"): setattr(instance, attr, value) class TestGate5ContractValidation: """Gate 5: canonical resource-path contracts.""" def test_parse_docs_path_returns_pure_posix_path(self) -> None: """Ensures boundary strings become path objects before publication.""" path = parse_docs_path("skills/demo/SKILL.md") assert path == PurePosixPath("skills/demo/SKILL.md") assert isinstance(path, PurePosixPath) @pytest.mark.parametrize( "value", ( "/absolute.md", "../outside.md", "skills\\demo\\SKILL.md", "skills//demo/SKILL.md", "skills/demo/README.txt", ), ) def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None: """Ensures non-canonical docs paths fail contract validation.""" with pytest.raises(ValueError): parse_docs_path(value) def test_reference_entry_materializes_reference_path(self) -> None: """Ensures authored reference strings become constrained path objects.""" entry = ReferenceEntry.model_validate({"path": "references/guides/setup.md"}) assert entry.path == PurePosixPath("references/guides/setup.md") assert parse_reference_path(entry.path) == entry.path @pytest.mark.parametrize("value", ("guide.md", "other/guide.md", "references.md")) def test_reference_path_stays_under_references(self, value: str) -> None: """Ensures in-skill references remain below the references directory.""" with pytest.raises(ValueError, match="stay under references"): parse_reference_path(value) class TestGate6FreezeValidation: """Gate 6: immutable in-memory registry snapshot semantics.""" def test_docs_registry_copies_mapping_inputs(self) -> None: """Ensures registry snapshots are isolated from caller-owned mapping mutations.""" index_path = PurePosixPath("index.md") source_docs = {index_path: "# index\n"} registry = DocsRegistry( skills_by_id={}, skills_in_load_order=(), skills_summary_in_load_order=(), docs_markdown_by_path=source_docs, docs_markdown_path_index=(index_path,), tag_to_skill_ids={}, capability_to_skill_ids={}, prompts_by_id={}, prompts_in_load_order=(), prompts_summary_in_load_order=(), tag_to_prompt_ids={}, ) source_docs[PurePosixPath("other.md")] = "# other\n" assert PurePosixPath("other.md") not in registry.docs_markdown_by_path assert registry.docs_markdown_path_index == (index_path,) def test_docs_registry_instance_is_frozen(self) -> None: """Ensures frozen model prevents attribute reassignment.""" registry = DocsRegistry( skills_by_id={}, skills_in_load_order=(), skills_summary_in_load_order=(), docs_markdown_by_path={}, docs_markdown_path_index=(), tag_to_skill_ids={}, capability_to_skill_ids={}, prompts_by_id={}, prompts_in_load_order=(), prompts_summary_in_load_order=(), tag_to_prompt_ids={}, ) assert_model_is_frozen( registry, attr="docs_markdown_path_index", value=(PurePosixPath("index.md"),), )