started model tests

This commit is contained in:
John Lancaster
2026-06-21 17:09:03 -05:00
parent 37fa9b6c6f
commit c189677717
7 changed files with 582 additions and 11 deletions
@@ -0,0 +1,171 @@
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",))
@@ -0,0 +1,195 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
from personal_mcp.registry.load import _build_prompt_record
from personal_mcp.registry.load import _to_prompt_summary
from personal_mcp.registry.load import load_docs_registry
from tests.registry.models.test_document_validation import as_markdown
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_prompt_frontmatter_payload
from tests.registry.models.test_document_validation import make_skill_frontmatter_payload
pytestmark = pytest.mark.unit
def _make_prompt_bundle(
*,
slug: str,
frontmatter: str | None,
other_files: tuple[str, ...] = (),
) -> PromptFilesBundle:
prompt = make_markdown_document(
f"prompts/{slug}/PROMPT.md",
frontmatter=frontmatter,
)
other = tuple(make_markdown_document(f"prompts/{slug}/{filename}") for filename in other_files)
return PromptFilesBundle(slug=slug, prompt=prompt, other=other)
class TestPromptValidationGates:
"""Gate-oriented validation coverage for prompt conversion."""
class TestGate1LayoutValidation:
"""Gate 1: enforce required source shape before metadata parsing."""
def test_missing_frontmatter_fails_fast(self) -> None:
"""Ensures conversion rejects missing prompt frontmatter at the layout gate."""
bundle = _make_prompt_bundle(slug="initial", frontmatter=None)
with pytest.raises(ValueError, match="missing YAML frontmatter"):
_build_prompt_record(bundle=bundle)
class TestGate2MetadataValidation:
"""Gate 2: validate prompt metadata via pydantic models."""
def test_rejects_non_semver_version(self) -> None:
"""Ensures semver violations fail during prompt model validation."""
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial", version="not-semver")
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
with pytest.raises(ValidationError, match="version must be semver"):
_build_prompt_record(bundle=bundle)
def test_rejects_invalid_argument_names(self) -> None:
"""Ensures prompt argument keys use Python-identifier naming rules."""
frontmatter = make_prompt_frontmatter_payload(
prompt_id="initial",
arguments={
"invalid-name": {
"type": "string",
"required": True,
}
},
)
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
with pytest.raises(ValidationError, match="invalid prompt argument name"):
_build_prompt_record(bundle=bundle)
def test_rejects_missing_primary_capability(self) -> None:
"""Ensures canonical prompt document capability is required."""
frontmatter = make_prompt_frontmatter_payload(
prompt_id="initial",
capabilities=("resource://catalog/prompts_index",),
)
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
with pytest.raises(ValueError, match="capabilities must include"):
_build_prompt_record(bundle=bundle)
class TestGate3ResourceValidation:
"""Gate 3: resolve prompt document resources and argument schema."""
def test_assigns_canonical_document_uri(self) -> None:
"""Ensures prompt records emit canonical document URIs."""
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
assert record.document_uri == "resource://prompts/initial/document"
assert record.document_relpath == "prompts/initial/PROMPT.md"
def test_preserves_argument_schema(self) -> None:
"""Ensures argument metadata survives conversion unchanged."""
frontmatter = make_prompt_frontmatter_payload(
prompt_id="initial",
arguments={
"topic": {
"type": "string",
"required": True,
"description": "topic to discuss",
}
},
)
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
assert record.arguments["topic"].required is True
assert record.arguments["topic"].type == "string"
assert record.arguments["topic"].description == "topic to discuss"
class TestGate4GraphValidation:
"""Gate 4: validate cross-entity identifier coherence."""
def test_prompt_id_collision_with_skill_id_fails(self, tmp_path) -> None:
"""Ensures prompt and skill ids cannot collide in published registry."""
skill_dir = tmp_path / "skills" / "shared"
prompt_dir = tmp_path / "prompts" / "shared"
skill_dir.mkdir(parents=True)
prompt_dir.mkdir(parents=True)
skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared")
prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared")
(skill_dir / "SKILL.md").write_text(as_markdown(skill_frontmatter), encoding="utf-8")
(prompt_dir / "PROMPT.md").write_text(as_markdown(prompt_frontmatter), encoding="utf-8")
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
class TestGate5ContractValidation:
"""Gate 5: validate model_dump contract shape for API surfaces."""
def test_prompt_record_model_dump_contains_contract_fields(self) -> None:
"""Ensures prompt record serialization includes canonical fields."""
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
dumped = record.model_dump()
assert dumped["prompt_id"] == "initial"
assert dumped["document_uri"] == "resource://prompts/initial/document"
assert "arguments" in dumped
assert "document_content" in dumped
def test_prompt_summary_projection_stays_stable(self) -> None:
"""Ensures prompt summary shape remains deterministic for index responses."""
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
summary = _to_prompt_summary(record)
assert summary.model_dump() == {
"prompt_id": "initial",
"name": "initial",
"description": "demo prompt",
"tags": ("testing",),
"capabilities": ("resource://prompts/initial/document",),
"document_uri": "resource://prompts/initial/document",
"version": "1.0.0",
}
class TestGate6FreezeValidation:
"""Gate 6: ensure immutable runtime records."""
def test_prompt_record_instance_is_frozen(self) -> None:
"""Ensures validated prompt records cannot be mutated after creation."""
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
assert_model_is_frozen(record, attr="name", value="mutated")
def test_argument_entry_values_are_frozen(self) -> None:
"""Ensures nested prompt argument entries are immutable after publication."""
frontmatter = make_prompt_frontmatter_payload(
prompt_id="initial",
arguments={
"topic": {
"type": "string",
"required": True,
}
},
)
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
with pytest.raises(ValidationError, match="Instance is frozen"):
record.arguments["topic"].required = False
@@ -0,0 +1,205 @@
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.load import _to_summary
from personal_mcp.registry.load import load_docs_registry
from tests.registry.models.test_document_validation import as_markdown
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 TestGate4GraphValidation:
"""Gate 4: validate dependency graph coherence across skills."""
def test_unknown_dependency_fails_registry_load(self, tmp_path) -> None:
"""Ensures unresolved depends_on targets abort registry publication."""
skill_dir = tmp_path / "skills" / "alpha"
skill_dir.mkdir(parents=True)
frontmatter = make_skill_frontmatter_payload(skill_id="alpha", depends_on=("beta",))
(skill_dir / "SKILL.md").write_text(as_markdown(frontmatter), encoding="utf-8")
with pytest.raises(ValueError, match="depends_on unknown skill"):
load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
def test_resolved_dependency_succeeds_registry_load(self, tmp_path) -> None:
"""Ensures valid dependency graph survives graph validation."""
alpha_dir = tmp_path / "skills" / "alpha"
beta_dir = tmp_path / "skills" / "beta"
alpha_dir.mkdir(parents=True)
beta_dir.mkdir(parents=True)
alpha_frontmatter = make_skill_frontmatter_payload(skill_id="alpha", depends_on=("beta",))
beta_frontmatter = make_skill_frontmatter_payload(skill_id="beta")
(alpha_dir / "SKILL.md").write_text(as_markdown(alpha_frontmatter), encoding="utf-8")
(beta_dir / "SKILL.md").write_text(as_markdown(beta_frontmatter), encoding="utf-8")
registry = load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
assert registry.skills_by_id["alpha"].depends_on == ("beta",)
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 = _to_summary(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))
with pytest.raises(ValidationError, match="Instance is frozen"):
record.references["guide"].title = "Mutated"