172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
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,
|
|
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 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",))
|