From 9971cf017e07829875a77fc053f6ae4c7b2fbb7d Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 4 Aug 2026 11:26:27 -0500 Subject: [PATCH] Add CI validation and tests --- .github/workflows/ci.yml | 49 ++++++++++ requirements-dev.txt | 2 + ruff.toml | 5 + scripts/check_repository.py | 178 ++++++++++++++++++++++++++++++++++++ tests/test_repository.py | 48 ++++++++++ 5 files changed, 282 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 requirements-dev.txt create mode 100644 ruff.toml create mode 100644 scripts/check_repository.py create mode 100644 tests/test_repository.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56d2374 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate and test + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install CI dependencies + run: python -m pip install --requirement requirements-dev.txt + + - name: Lint Python + run: ruff check . + + - name: Check Python formatting + run: ruff format --check . + + - name: Validate repository files + run: python scripts/check_repository.py + + - name: Run tests + run: python -m unittest discover --start-directory tests --verbose diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a4246a3 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +PyYAML==6.0.3 +ruff==0.15.22 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..0f62d9f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,5 @@ +target-version = "py312" +line-length = 100 + +[lint] +select = ["E", "F", "I", "UP"] diff --git a/scripts/check_repository.py b/scripts/check_repository.py new file mode 100644 index 0000000..acf9697 --- /dev/null +++ b/scripts/check_repository.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Validate the repository's structured and text files.""" + +from __future__ import annotations + +import json +import re +import sys +import tomllib +import urllib.parse +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SKIPPED_DIRECTORIES = { + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", +} +TEXT_SUFFIXES = { + ".json", + ".md", + ".py", + ".svg", + ".toml", + ".txt", + ".yaml", + ".yml", +} +MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") + + +class DuplicateKeyError(ValueError): + """Raised when a JSON object contains a duplicate key.""" + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate key {key!r}") + result[key] = value + return result + + +def repository_files() -> list[Path]: + return sorted( + path + for path in ROOT.rglob("*") + if path.is_file() and not SKIPPED_DIRECTORIES.intersection(path.relative_to(ROOT).parts) + ) + + +def validate_text(path: Path, errors: list[str]) -> str | None: + if path.suffix.lower() not in TEXT_SUFFIXES: + return None + + relative = path.relative_to(ROOT) + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + errors.append(f"{relative}: not valid UTF-8 ({exc})") + return None + + if text and not text.endswith("\n"): + errors.append(f"{relative}: missing final newline") + for number, line in enumerate(text.splitlines(), start=1): + if line != line.rstrip(): + errors.append(f"{relative}:{number}: trailing whitespace") + return text + + +def validate_json(path: Path, text: str, errors: list[str]) -> None: + try: + json.loads(text, object_pairs_hook=reject_duplicate_keys) + except (json.JSONDecodeError, DuplicateKeyError) as exc: + errors.append(f"{path.relative_to(ROOT)}: invalid JSON ({exc})") + + +def validate_toml(path: Path, text: str, errors: list[str]) -> None: + try: + tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + errors.append(f"{path.relative_to(ROOT)}: invalid TOML ({exc})") + + +def validate_yaml(path: Path, text: str, errors: list[str]) -> None: + try: + yaml.safe_load(text) + except yaml.YAMLError as exc: + errors.append(f"{path.relative_to(ROOT)}: invalid YAML ({exc})") + + +def validate_svg(path: Path, errors: list[str]) -> None: + try: + ET.parse(path) + except ET.ParseError as exc: + errors.append(f"{path.relative_to(ROOT)}: invalid XML/SVG ({exc})") + + +def markdown_target(raw_target: str) -> str: + target = raw_target.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + elif " " in target: + target = target.split(" ", maxsplit=1)[0] + return urllib.parse.unquote(target.split("#", maxsplit=1)[0]) + + +def validate_markdown_links(path: Path, text: str, errors: list[str]) -> None: + for match in MARKDOWN_LINK.finditer(text): + target = markdown_target(match.group(1)) + if not target or target.startswith(("http://", "https://", "mailto:")): + continue + resolved = (path.parent / target).resolve() + if not resolved.exists(): + errors.append(f"{path.relative_to(ROOT)}: broken local link {target!r}") + + +def validate_skill(path: Path, text: str, errors: list[str]) -> None: + relative = path.relative_to(ROOT) + if not text.startswith("---\n"): + errors.append(f"{relative}: missing YAML frontmatter") + return + try: + frontmatter, _body = text[4:].split("\n---\n", maxsplit=1) + metadata = yaml.safe_load(frontmatter) + except (ValueError, yaml.YAMLError) as exc: + errors.append(f"{relative}: invalid YAML frontmatter ({exc})") + return + if not isinstance(metadata, dict): + errors.append(f"{relative}: frontmatter must be a mapping") + return + if metadata.get("name") != path.parent.name: + errors.append(f"{relative}: skill name must match directory name {path.parent.name!r}") + if not isinstance(metadata.get("description"), str) or not metadata["description"].strip(): + errors.append(f"{relative}: skill description must be a non-empty string") + + +def main() -> int: + errors: list[str] = [] + files = repository_files() + + for path in files: + text = validate_text(path, errors) + suffix = path.suffix.lower() + if text is not None and suffix == ".json": + validate_json(path, text, errors) + elif text is not None and suffix == ".toml": + validate_toml(path, text, errors) + elif text is not None and suffix in {".yaml", ".yml"}: + validate_yaml(path, text, errors) + elif suffix == ".svg": + validate_svg(path, errors) + + if text is not None and suffix == ".md": + validate_markdown_links(path, text, errors) + if text is not None and path.name == "SKILL.md": + validate_skill(path, text, errors) + + if errors: + print("Repository validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Validated {len(files)} repository files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..8ff66c1 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] + + +def load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +class CursorPluginRepositoryTests(unittest.TestCase): + def setUp(self) -> None: + self.marketplace = load_json(ROOT / ".cursor-plugin/marketplace.json") + self.listing = self.marketplace["plugins"][0] + self.plugin_root = ROOT / self.listing["source"] + self.manifest = load_json(self.plugin_root / ".cursor-plugin/plugin.json") + + def test_marketplace_points_to_plugin(self) -> None: + self.assertEqual(self.marketplace["name"], self.manifest["name"]) + self.assertEqual(self.listing["name"], self.manifest["name"]) + self.assertTrue(self.plugin_root.is_dir()) + + def test_manifest_references_existing_assets(self) -> None: + self.assertTrue((self.plugin_root / self.manifest["logo"]).is_file()) + self.assertTrue((self.plugin_root / "skills/sprites/SKILL.md").is_file()) + + def test_manifest_repository_matches_this_repository(self) -> None: + self.assertEqual( + self.manifest["repository"], + "https://github.com/superfly/sprites-cursor-plugin", + ) + + def test_mcp_server_uses_https(self) -> None: + config = load_json(self.plugin_root / "mcp.json") + server = config["mcpServers"]["sprites"] + parsed = urlparse(server["url"]) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "sprites.dev") + self.assertEqual(parsed.path, "/mcp") + + +if __name__ == "__main__": + unittest.main()