From c189677717699395570eb93c443b6f3be0ad5474 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:09:03 -0500 Subject: [PATCH] started model tests --- src/personal_mcp/registry/load.py | 2 +- src/personal_mcp/registry/models/common.py | 9 + src/personal_mcp/registry/read.py | 2 +- src/personal_mcp/skills/document_loader.py | 9 - .../models/test_document_validation.py | 171 +++++++++++++++ .../registry/models/test_prompt_validation.py | 195 +++++++++++++++++ .../registry/models/test_skill_validation.py | 205 ++++++++++++++++++ 7 files changed, 582 insertions(+), 11 deletions(-) create mode 100644 tests/registry/models/test_document_validation.py create mode 100644 tests/registry/models/test_prompt_validation.py create mode 100644 tests/registry/models/test_skill_validation.py diff --git a/src/personal_mcp/registry/load.py b/src/personal_mcp/registry/load.py index 8ec2d3b..bd016c2 100644 --- a/src/personal_mcp/registry/load.py +++ b/src/personal_mcp/registry/load.py @@ -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.skill import SkillFilesBundle 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 PromptRecord from personal_mcp.registry.models.registry import PromptSummaryRecord from personal_mcp.registry.models.registry import ReferenceRecord from personal_mcp.registry.models.registry import SkillRecord 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 _title_from_reference_filename from personal_mcp.skills.document_loader import _validate_prompt_frontmatter diff --git a/src/personal_mcp/registry/models/common.py b/src/personal_mcp/registry/models/common.py index 242cf2c..88af0bc 100644 --- a/src/personal_mcp/registry/models/common.py +++ b/src/personal_mcp/registry/models/common.py @@ -47,3 +47,12 @@ class ReferenceEntry(StrictFrozenModel): if path.suffix.lower() != ".md": raise ValueError("reference path must target a markdown file") 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() diff --git a/src/personal_mcp/registry/read.py b/src/personal_mcp/registry/read.py index ffd3fb3..97e676b 100644 --- a/src/personal_mcp/registry/read.py +++ b/src/personal_mcp/registry/read.py @@ -1,5 +1,5 @@ -from ..skills.document_loader import _normalize_docs_path from .contracts import DocsRegistry +from .models.common import _normalize_docs_path def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]: diff --git a/src/personal_mcp/skills/document_loader.py b/src/personal_mcp/skills/document_loader.py index 744dce2..041039b 100644 --- a/src/personal_mcp/skills/document_loader.py +++ b/src/personal_mcp/skills/document_loader.py @@ -80,15 +80,6 @@ def _validate_prompt_frontmatter(raw: dict[str, Any], *, prompt_dir_name: str) - 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: stem = PurePosixPath(filename).stem normalized = stem.replace("-", " ").replace("_", " ").split() diff --git a/tests/registry/models/test_document_validation.py b/tests/registry/models/test_document_validation.py new file mode 100644 index 0000000..77bd485 --- /dev/null +++ b/tests/registry/models/test_document_validation.py @@ -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",)) diff --git a/tests/registry/models/test_prompt_validation.py b/tests/registry/models/test_prompt_validation.py new file mode 100644 index 0000000..2c40057 --- /dev/null +++ b/tests/registry/models/test_prompt_validation.py @@ -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 diff --git a/tests/registry/models/test_skill_validation.py b/tests/registry/models/test_skill_validation.py new file mode 100644 index 0000000..a52192c --- /dev/null +++ b/tests/registry/models/test_skill_validation.py @@ -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"