185 lines
6.3 KiB
Python
185 lines
6.3 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.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"),),
|
|
)
|