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.load import _parse_frontmatter from personal_mcp.registry.models.common import _normalize_docs_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, depends_on: tuple[str, ...] = (), references: dict[str, dict[str, Any]] | None = None, ) -> str: """Builds frontmatter payload YAML for skill conversion tests.""" canonical_name = name or skill_id payload: dict[str, Any] = { "name": canonical_name, "description": description, "x-personal-mcp": { "id": skill_id, "version": version, "tags": list(tags), "capabilities": list(capabilities or (f"resource://skills/{canonical_name}/document",)), "depends_on": list(depends_on), }, } if references is not None: payload["x-personal-mcp"]["references"] = references 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 TestGate1LayoutValidation: """Gate 1: source layout and parse shape constraints.""" def test_parse_frontmatter_requires_payload(self) -> None: """Ensures missing frontmatter fails before metadata validation.""" with pytest.raises(ValueError, match="missing YAML frontmatter"): _parse_frontmatter(None, path=PurePosixPath("skills/alpha/SKILL.md")) def test_parse_frontmatter_requires_mapping(self) -> None: """Ensures non-object YAML payloads are rejected at parse time.""" with pytest.raises(TypeError, match="frontmatter must parse to an object"): _parse_frontmatter("- one\n- two\n", path=PurePosixPath("skills/alpha/SKILL.md")) def test_parse_frontmatter_accepts_mapping(self) -> None: """Ensures valid mapping payload is returned for downstream validation.""" parsed = _parse_frontmatter("name: alpha\n", path=PurePosixPath("skills/alpha/SKILL.md")) assert parsed == {"name": "alpha"} class TestGate5ContractValidation: """Gate 5: canonical resource-path contract normalization.""" def test_normalize_docs_path_keeps_posix_relative_paths(self) -> None: """Ensures docs paths remain normalized before registry publication.""" assert _normalize_docs_path("skills/demo/SKILL.md") == "skills/demo/SKILL.md" def test_normalize_docs_path_rejects_parent_traversal(self) -> None: """Ensures traversal attempts fail contract validation.""" with pytest.raises(ValueError, match="normalized docs-relative path"): _normalize_docs_path("../outside.md") 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.""" source_docs = {"index.md": "# 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.md",), 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["other.md"] = "# other\n" assert "other.md" not in registry.docs_markdown_by_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=("index.md",))