Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-compass-manifests validate-skill-design validate-skill-design-changed validate-mcp-tools validate-spelling package clean check-uv
.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-compass-manifests validate-lifecycle-ceiling validate-skill-design validate-skill-design-changed validate-mcp-tools validate-spelling package clean check-uv

help:
@echo "agentic-plugins"
Expand All @@ -10,6 +10,7 @@ help:
@echo " validate-collection-schema - Schema + roster + banners (subset of compliance)"
@echo " validate-collection-compliance - Full .catalog compliance (includes collection.json drift)"
@echo " validate-compass-manifests - Compass manifests, roster, refs, and skill references/ layout"
@echo " validate-lifecycle-ceiling - Compass lifecycle ceiling (skill <= plugin lifecycle) + unit tests"
@echo " validate-skill-design - Validate all skills (use PACK=rh-sre for a specific pack)"
@echo " validate-skill-design-changed - Validate only changed skills (staged + unstaged, for local dev)"
@echo " validate-mcp-tools - Validate allowed-tools against live MCP servers (requires podman)"
Expand Down Expand Up @@ -62,6 +63,10 @@ validate: check-uv
uv run python scripts/validate_collection_compliance.py || EXIT=1; \
echo "=== Validating Compass manifests..."; \
uv run python scripts/validate_compass_manifests.py || EXIT=1; \
echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \
uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \
echo "=== Running lifecycle ceiling unit tests..."; \
uv run pytest scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \
echo "=== Validating MCP tool references (skips gracefully without podman)..."; \
uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \
echo "=== Validating skill design principles..."; \
Expand All @@ -88,6 +93,10 @@ validate-structure: check-uv
uv run python scripts/validate_collection_compliance.py || EXIT=1; \
echo "=== Validating Compass manifests..."; \
uv run python scripts/validate_compass_manifests.py || EXIT=1; \
echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \
uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \
echo "=== Running lifecycle ceiling unit tests..."; \
uv run pytest scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \
echo "=== Validating MCP tool references (skips gracefully without podman)..."; \
uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \
echo "=== Validation complete!"; \
Expand All @@ -102,6 +111,10 @@ validate-collection-compliance: check-uv
validate-compass-manifests: check-uv
@uv run python scripts/validate_compass_manifests.py

validate-lifecycle-ceiling: check-uv
@uv run python scripts/validate_lifecycle_ceiling.py
@uv run pytest scripts/test_validate_lifecycle_ceiling.py

validate-skill-design: check-uv
@uv run python scripts/validate_skills_tier2.py $(if $(PACK),$(PACK))

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dependencies = [
dev = [
"codespell>=2.3.0",
"pre-commit>=4.0.0",
"pytest>=8.0",
]
256 changes: 256 additions & 0 deletions scripts/test_validate_lifecycle_ceiling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Pytest unit tests for the Compass lifecycle ceiling validator."""

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

import pytest
import yaml

_SCRIPTS = Path(__file__).resolve().parent


def _load_module(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, _SCRIPTS / filename)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[name] = module
spec.loader.exec_module(module)
return module


lifecycle_ceiling = _load_module("validate_lifecycle_ceiling", "validate_lifecycle_ceiling.py")


def _write_manifest(path: Path, *, name: str, kind: str = "AiResource", lifecycle: str | None = "__unset__") -> None:
"""Write a minimal Compass manifest. lifecycle='__unset__' omits the field entirely."""
data: dict = {
"apiVersion": "backstage.io/v1alpha1",
"kind": kind,
"metadata": {"name": name},
"spec": {},
}
if lifecycle != "__unset__":
data["spec"]["lifecycle"] = lifecycle
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")


def _write_root_catalog(root: Path, packs: list[str]) -> None:
data = {
"apiVersion": "backstage.io/v1alpha1",
"kind": "Location",
"metadata": {"name": "agentic-plugins"},
"spec": {"targets": [f"./{pack}/catalog-info.yaml" for pack in packs] + ["./mcps/catalog-info.yaml"]},
}
(root / "catalog-info.yaml").write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
(root / "mcps").mkdir(parents=True, exist_ok=True)
(root / "mcps" / "catalog-info.yaml").write_text(
yaml.safe_dump({"apiVersion": "backstage.io/v1alpha1", "kind": "Location", "spec": {"targets": []}}),
encoding="utf-8",
)


def _write_pack(
root: Path,
pack: str,
*,
plugin_lifecycle: str | None = "__unset__",
skills: dict[str, str | None] | None = None,
) -> None:
"""Create <pack>/<pack>-plugin.yaml and <pack>/skills/<skill>/catalog-info.yaml files."""
pack_dir = root / pack
_write_manifest(pack_dir / f"{pack}-plugin.yaml", name=pack, lifecycle=plugin_lifecycle)
for skill_name, skill_lifecycle in (skills or {}).items():
_write_manifest(
pack_dir / "skills" / skill_name / "catalog-info.yaml",
name=skill_name,
lifecycle=skill_lifecycle,
)
(root / pack / "catalog-info.yaml").write_text(
yaml.safe_dump({"apiVersion": "backstage.io/v1alpha1", "kind": "Location", "spec": {"targets": []}}),
encoding="utf-8",
)


@pytest.fixture
def repo_root(tmp_path: Path) -> Path:
return tmp_path


class TestLifecycleRank:
def test_known_lifecycles_ordered(self) -> None:
assert lifecycle_ceiling.lifecycle_rank("development") == 0
assert lifecycle_ceiling.lifecycle_rank("beta") == 1
assert lifecycle_ceiling.lifecycle_rank("production") == 2

def test_missing_lifecycle_defaults_to_development(self) -> None:
assert lifecycle_ceiling.lifecycle_rank(None) == lifecycle_ceiling.lifecycle_rank("development")

def test_unknown_lifecycle_raises(self) -> None:
with pytest.raises(ValueError):
lifecycle_ceiling.lifecycle_rank("ga")

def test_is_deprecated(self) -> None:
assert lifecycle_ceiling.is_deprecated("deprecated") is True
assert lifecycle_ceiling.is_deprecated("Deprecated") is True
assert lifecycle_ceiling.is_deprecated("beta") is False
assert lifecycle_ceiling.is_deprecated(None) is False


class TestPassingCases:
def test_skill_equal_to_plugin_passes(self, repo_root: Path) -> None:
_write_pack(repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "beta"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []

def test_skill_less_mature_than_plugin_passes(self, repo_root: Path) -> None:
_write_pack(repo_root, "rh-demo", plugin_lifecycle="production", skills={"demo-skill": "development"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []

def test_missing_lifecycles_default_to_development_and_pass(self, repo_root: Path) -> None:
# Neither plugin nor skill declares spec.lifecycle -> both default to development.
_write_pack(repo_root, "rh-demo", plugin_lifecycle="__unset__", skills={"demo-skill": "__unset__"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []

def test_multiple_skills_all_within_ceiling(self, repo_root: Path) -> None:
_write_pack(
repo_root,
"rh-demo",
plugin_lifecycle="beta",
skills={"skill-a": "development", "skill-b": "beta"},
)

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []


class TestFailCase:
def test_skill_more_mature_than_plugin_fails(self, repo_root: Path) -> None:
_write_pack(repo_root, "rh-demo", plugin_lifecycle="development", skills={"demo-skill": "beta"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert len(errors) == 1
assert "demo-skill" in errors[0]
assert "'beta'" in errors[0]
assert "'development'" in errors[0]

def test_production_skill_under_beta_plugin_fails(self, repo_root: Path) -> None:
_write_pack(repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "production"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert len(errors) == 1

def test_only_offending_skill_is_reported(self, repo_root: Path) -> None:
_write_pack(
repo_root,
"rh-demo",
plugin_lifecycle="development",
skills={"ok-skill": "development", "bad-skill": "production"},
)

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert len(errors) == 1
assert "bad-skill" in errors[0]
assert "ok-skill" not in errors[0]


class TestDeprecatedSkipLogic:
def test_deprecated_skill_is_skipped_even_if_more_mature(self, repo_root: Path) -> None:
_write_pack(repo_root, "rh-demo", plugin_lifecycle="development", skills={"demo-skill": "deprecated"})

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []

def test_deprecated_plugin_skips_all_skills(self, repo_root: Path) -> None:
_write_pack(
repo_root,
"rh-demo",
plugin_lifecycle="deprecated",
skills={"demo-skill": "production"},
)

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert errors == []

def test_deprecated_skill_among_others_only_skips_itself(self, repo_root: Path) -> None:
_write_pack(
repo_root,
"rh-demo",
plugin_lifecycle="development",
skills={"deprecated-skill": "deprecated", "bad-skill": "beta"},
)

errors: list[str] = []
lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors)

assert len(errors) == 1
assert "bad-skill" in errors[0]
assert "deprecated-skill" not in errors[0]


class TestValidateAll:
def test_validate_all_discovers_registered_packs_from_root_catalog(self, repo_root: Path) -> None:
_write_root_catalog(repo_root, ["rh-good", "rh-bad"])
_write_pack(repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"})
_write_pack(repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"})

errors = lifecycle_ceiling.validate_all(repo_root)

assert len(errors) == 1
assert "bad-skill" in errors[0]

def test_validate_all_ignores_unregistered_packs(self, repo_root: Path) -> None:
# rh-bad exists on disk but is not listed in the root catalog-info.yaml targets.
_write_root_catalog(repo_root, ["rh-good"])
_write_pack(repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"})
_write_pack(repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"})

errors = lifecycle_ceiling.validate_all(repo_root)

assert errors == []

def test_validate_all_missing_root_catalog_reports_error(self, repo_root: Path) -> None:
errors = lifecycle_ceiling.validate_all(repo_root)

assert len(errors) == 1
assert "catalog-info.yaml" in errors[0]

def test_pack_missing_plugin_manifest_reports_error(self, repo_root: Path) -> None:
_write_root_catalog(repo_root, ["rh-orphan"])
(repo_root / "rh-orphan").mkdir(parents=True)

errors = lifecycle_ceiling.validate_all(repo_root)

assert len(errors) == 1
assert "rh-orphan" in errors[0]


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading
Loading