started model tests
This commit is contained in:
@@ -12,13 +12,13 @@ from personal_mcp.registry.ingest.document import MarkdownDocument
|
|||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||||
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
||||||
from personal_mcp.registry.models.common import ReferenceEntry
|
from personal_mcp.registry.models.common import ReferenceEntry
|
||||||
|
from personal_mcp.registry.models.common import _normalize_docs_path
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||||
from personal_mcp.registry.models.registry import ReferenceRecord
|
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||||
from personal_mcp.registry.models.registry import SkillRecord
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||||
from personal_mcp.skills.document_loader import _normalize_docs_path
|
|
||||||
from personal_mcp.skills.document_loader import _reference_id_from_filename
|
from personal_mcp.skills.document_loader import _reference_id_from_filename
|
||||||
from personal_mcp.skills.document_loader import _title_from_reference_filename
|
from personal_mcp.skills.document_loader import _title_from_reference_filename
|
||||||
from personal_mcp.skills.document_loader import _validate_prompt_frontmatter
|
from personal_mcp.skills.document_loader import _validate_prompt_frontmatter
|
||||||
|
|||||||
@@ -47,3 +47,12 @@ class ReferenceEntry(StrictFrozenModel):
|
|||||||
if path.suffix.lower() != ".md":
|
if path.suffix.lower() != ".md":
|
||||||
raise ValueError("reference path must target a markdown file")
|
raise ValueError("reference path must target a markdown file")
|
||||||
return path.as_posix()
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_docs_path(path: str) -> str:
|
||||||
|
normalized = PurePosixPath(path)
|
||||||
|
if normalized.is_absolute() or ".." in normalized.parts:
|
||||||
|
raise ValueError("path must be a normalized docs-relative path")
|
||||||
|
if normalized.suffix.lower() != ".md":
|
||||||
|
raise ValueError("path must point to a markdown file")
|
||||||
|
return normalized.as_posix()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from ..skills.document_loader import _normalize_docs_path
|
|
||||||
from .contracts import DocsRegistry
|
from .contracts import DocsRegistry
|
||||||
|
from .models.common import _normalize_docs_path
|
||||||
|
|
||||||
|
|
||||||
def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]:
|
def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]:
|
||||||
|
|||||||
@@ -80,15 +80,6 @@ def _validate_prompt_frontmatter(raw: dict[str, Any], *, prompt_dir_name: str) -
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _normalize_docs_path(path: str) -> str:
|
|
||||||
normalized = PurePosixPath(path)
|
|
||||||
if normalized.is_absolute() or ".." in normalized.parts:
|
|
||||||
raise ValueError("path must be a normalized docs-relative path")
|
|
||||||
if normalized.suffix.lower() != ".md":
|
|
||||||
raise ValueError("path must point to a markdown file")
|
|
||||||
return normalized.as_posix()
|
|
||||||
|
|
||||||
|
|
||||||
def _title_from_reference_filename(filename: str) -> str:
|
def _title_from_reference_filename(filename: str) -> str:
|
||||||
stem = PurePosixPath(filename).stem
|
stem = PurePosixPath(filename).stem
|
||||||
normalized = stem.replace("-", " ").replace("_", " ").split()
|
normalized = stem.replace("-", " ").replace("_", " ").split()
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user