From 5ce7651938783e7f21bdba2326f309fdc21b3e3c Mon Sep 17 00:00:00 2001 From: PerhapsSPY Date: Wed, 29 Jul 2026 23:17:03 +0900 Subject: [PATCH] Formalize plugin release operations --- .editorconfig | 15 ++ .gitattributes | 6 + .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 32 +++ .github/workflows/release.yml | 37 ++++ .gitignore | 12 ++ AGENTS.md | 14 ++ CHANGELOG.en.md | 13 ++ CHANGELOG.md | 13 ++ CONTRIBUTING.en.md | 41 ++++ CONTRIBUTING.md | 41 ++++ README.en.md | 51 +++++ README.md | 41 +++- docs/PRODUCT.md | 31 +++ scripts/validate_plugin.py | 358 ++++++++++++++++++++++++++++++++++ tests/test_validate_plugin.py | 122 ++++++++++++ 16 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.en.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.en.md create mode 100644 CONTRIBUTING.md create mode 100644 README.en.md create mode 100644 docs/PRODUCT.md create mode 100644 scripts/validate_plugin.py create mode 100644 tests/test_validate_plugin.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2e0d78c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..81da2f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..088250d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @perhapsspy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9a96ffa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Run unit tests + run: python -m unittest discover -s tests -p 'test_*.py' -v + + - name: Validate plugin + run: python scripts/validate_plugin.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a7fe31d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Run unit tests + run: python -m unittest discover -s tests -p 'test_*.py' -v + + - name: Validate plugin + run: python scripts/validate_plugin.py + + - name: Verify release tag matches plugin version + run: python scripts/validate_plugin.py --release-tag "$GITHUB_REF_NAME" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21c37dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +dist/ +build/ +*.egg-info/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1e7e180 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +- 일반 문서는 한국어로 작성하고, README·CONTRIBUTING·CHANGELOG는 한국어와 영어 쌍을 함께 갱신합니다. +- `plugins/judgment-craft`가 유일한 canonical 플러그인 사본입니다. mirror, snapshot, lock, 동기화 생성물을 추가하지 않습니다. +- 제품 약속, 세 activation 역할, calibrate 후 friction 조합, SemVer 의미는 `docs/PRODUCT.md`가 소유합니다. +- 변경 전 `CONTRIBUTING.md`의 검증·릴리스·롤백 절차를 따릅니다. marketplace는 검증된 릴리스 커밋의 전체 SHA만 가리켜야 합니다. + +## 검증 + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +python scripts/validate_plugin.py +git diff --check +``` diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md new file mode 100644 index 0000000..0ac2868 --- /dev/null +++ b/CHANGELOG.en.md @@ -0,0 +1,13 @@ +# Changelog + +[한국어](CHANGELOG.md) + +## Unreleased + +- No unreleased changes yet. + +## 0.1.0 - 2026-07-29 + +- Initial Judgment Craft release. +- Includes `$practical-judgment`, `$calibrate-judgment`, and `$friction-distillation`. +- Defines the baseline composition contract for direct judgment, correction recalibration, and recurring friction response. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..46dca39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# 변경 기록 + +[English](CHANGELOG.en.md) + +## Unreleased + +- 아직 릴리스되지 않은 변경 사항이 없습니다. + +## 0.1.0 - 2026-07-29 + +- Judgment Craft 최초 릴리스. +- `$practical-judgment`, `$calibrate-judgment`, `$friction-distillation` 스킬 포함. +- 직접 판단, 정정 재보정, 반복 마찰 대응의 기본 composition 계약 정의. diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md new file mode 100644 index 0000000..b791f28 --- /dev/null +++ b/CONTRIBUTING.en.md @@ -0,0 +1,41 @@ +# Contributing + +[한국어](CONTRIBUTING.md) + +## Change Ownership + +Content owner first. Edit the canonical plugin copy directly under `plugins/judgment-craft`. Do not add `sources.lock`, `sync_skills`, generated snapshots, or mirror flows. + +Before changing the product promise, skill roles, activation model, or composition meaning, update [docs/PRODUCT.md](docs/PRODUCT.md). + +## Version Rules + +- Patch: compatible wording, explanation, typo, validation, or documentation fixes that preserve the existing activation and composition contract. +- Minor: skill additions or removals, material trigger changes, composition role changes, or starter prompt changes. +- Major candidate: a source/package contract break, canonical plugin path change, or public contract break that installers or marketplace consumers rely on. + +## Validation + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +python scripts/validate_plugin.py +git diff --check +``` + +## Release + +1. Run validation. +2. Update the version in `plugins/judgment-craft/.codex-plugin/plugin.json` and both changelogs. +3. Open a PR and pass CI. +4. Create an immutable `v` tag on the same merged commit. +5. Confirm the release workflow checks `--release-tag v` and completes GitHub release creation. +6. Update the `perhapsspy/codex-plugins` marketplace pin to the release commit full SHA. +7. Run remote marketplace validation and an install round trip. + +## Rollback + +Re-pin the marketplace entry to the last validated release commit full SHA. Never move or overwrite a published tag. Fix forward and issue a new patch or minor release. + +## Explicit Exclusions + +While the plugin and skills share one canonical repo, do not add `sources.lock`, `sync_skills`, generated snapshots, or `THIRD_PARTY_NOTICES`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9c4ebbd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# 기여 가이드 + +[English](CONTRIBUTING.en.md) + +## 변경 소유권 + +콘텐츠 소유자를 먼저 확인합니다. 플러그인은 `plugins/judgment-craft`의 canonical 사본을 직접 편집합니다. `sources.lock`, `sync_skills`, 생성 snapshot, mirror 흐름은 추가하지 않습니다. + +제품 약속, 스킬 역할, activation 모델, composition 의미를 바꾸기 전에는 [docs/PRODUCT.md](docs/PRODUCT.md)를 갱신합니다. + +## 버전 규칙 + +- Patch: 기존 activation과 composition 계약을 유지하는 문구·설명·검증·문서 수정. +- Minor: 스킬 추가·삭제, 중요한 trigger 변경, composition 역할 변경, starter prompt 변경. +- Major 후보: 패키지 계약, 설치 경로, marketplace가 의존하는 공개 계약을 깨는 변경. + +## 검증 + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +python scripts/validate_plugin.py +git diff --check +``` + +## 릴리스 + +1. 검증 명령을 실행합니다. +2. manifest 버전과 두 changelog를 갱신합니다. +3. PR을 열고 CI를 통과시킵니다. +4. 같은 merge 커밋에 변경 불가능한 `v` 태그를 만듭니다. +5. release workflow가 `--release-tag v`을 확인하고 GitHub Release를 생성하는지 확인합니다. +6. `perhapsspy/codex-plugins` marketplace를 릴리스 커밋의 전체 SHA로 갱신합니다. +7. 원격 marketplace 검증과 설치 round trip을 수행합니다. + +## 롤백 + +marketplace 항목을 마지막 검증 릴리스 커밋의 전체 SHA로 다시 pin합니다. 공개 태그를 이동하거나 덮어쓰지 않습니다. + +## 명시적 제외 + +canonical 저장소를 공유하는 동안 `sources.lock`, `sync_skills`, 생성 snapshot, `THIRD_PARTY_NOTICES`를 추가하지 않습니다. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..3996f03 --- /dev/null +++ b/README.en.md @@ -0,0 +1,51 @@ +# Judgment Craft + +Judgment Craft is a Codex plugin for bounded judgment: direct current recommendations, explicit recalibration after correction, and the smallest sufficient response to recurring friction. + +Korean: [README.md](README.md) + +## Install + +```bash +codex plugin marketplace add perhapsspy/codex-plugins +codex plugin add judgment-craft@perhapsspy +``` + +Update: + +```bash +codex plugin marketplace upgrade perhapsspy +codex plugin add judgment-craft@perhapsspy +``` + +Remove: + +```bash +codex plugin remove judgment-craft@perhapsspy +``` + +## Skills + +| Skill | Use when | +| --- | --- | +| `$practical-judgment` | You need a direct recommendation for a current choice or judgment. | +| `$calibrate-judgment` | You explicitly corrected the criteria, scope, or meaning of a prior judgment. | +| `$friction-distillation` | You need to choose the response level for recurring friction. | + +When a corrected current judgment may also require recurrence prevention, use `$calibrate-judgment` first and then `$friction-distillation`. + +Package path: `plugins/judgment-craft/` + +The product promise and role boundaries are owned by [docs/PRODUCT.md](docs/PRODUCT.md). Follow [CONTRIBUTING.en.md](CONTRIBUTING.en.md) for changes. Keep [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md) aligned. + +After installation or update, start a new Codex task so the refreshed skills are loaded. + +## Development + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +python scripts/validate_plugin.py + +## License +[MIT](LICENSE) +``` diff --git a/README.md b/README.md index 018885d..b798026 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Judgment Craft -현재 판단을 명확히 하고, 사용자의 정정에 맞춰 재계산하며, 반복 마찰에 필요한 최소 대응을 고르는 3가지 스킬 패키지입니다. +Judgment Craft는 현재 선택에 대한 직접적인 판단, 명시적 정정 이후의 재보정, 반복되는 마찰에 대한 최소 충분 대응을 제공하는 Codex 플러그인입니다. + +English: [README.en.md](README.en.md) ## 설치 @@ -9,14 +11,41 @@ codex plugin marketplace add perhapsspy/codex-plugins codex plugin add judgment-craft@perhapsspy ``` -| 스킬 | 선택할 때 | +업데이트: + +```bash +codex plugin marketplace upgrade perhapsspy +codex plugin add judgment-craft@perhapsspy +``` + +제거: + +```bash +codex plugin remove judgment-craft@perhapsspy +``` + +## 스킬 + +| 스킬 | 사용 시점 | | --- | --- | -| `$practical-judgment` | 현재 선택에 대한 직접 권고가 필요할 때 | +| `$practical-judgment` | 현재 선택이나 판단에 대한 직접적인 추천이 필요할 때 | | `$calibrate-judgment` | 이전 판단의 기준·범위·의미를 명시적으로 정정했을 때 | -| `$friction-distillation` | 반복 마찰의 재발 방지 대응 수준을 결정할 때 | +| `$friction-distillation` | 반복되는 마찰에 맞는 대응 수준을 정해야 할 때 | -정정으로 현재 판단을 다시 계산한 뒤 재발 방지 여부까지 결정해야 한다면 `$calibrate-judgment`와 `$friction-distillation`을 순서대로 함께 사용하세요. +정정된 현재 판단이 재발 방지도 요구하면 `$calibrate-judgment`를 먼저 사용한 뒤 `$friction-distillation`으로 이어갑니다. 패키지 경로: `plugins/judgment-craft/` -MIT 라이선스입니다. +제품 약속과 역할 경계는 [docs/PRODUCT.md](docs/PRODUCT.md)가 소유합니다. 변경 시 [CONTRIBUTING.md](CONTRIBUTING.md)를 따르고, 영어 문서는 [CONTRIBUTING.en.md](CONTRIBUTING.en.md)와 [CHANGELOG.en.md](CHANGELOG.en.md)를 함께 갱신합니다. + +설치 또는 업데이트 후에는 새 Codex 작업을 시작해 갱신된 스킬이 로드되도록 합니다. + +## 개발 + +```bash +python -m unittest discover -s tests -p "test_*.py" -v +python scripts/validate_plugin.py + +## 라이선스 +[MIT](LICENSE) +``` diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..99c1517 --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,31 @@ +# Judgment Craft 제품 계약 + +Judgment Craft는 현재 상황에서 필요한 판단 유형을 분리하고, 그 판단에 필요한 최소 충분 응답을 고르는 플러그인입니다. + +## 제품 약속 + +- 현재 선택에는 즉시 실행 가능한 제한된 추천을 제공합니다. +- 사용자가 이전 판단의 기준·범위·의미를 정정하면 그 정정을 반영해 현재 판단을 다시 계산합니다. +- 반복되는 마찰에는 원인을 자동으로 구조화한다고 가정하지 않고, 증거와 비용에 맞는 최소 충분 개입을 고릅니다. + +## 세 가지 activation 역할 + +`$practical-judgment`는 현재 선택이나 판단에 대한 직접 추천을 담당합니다. 과거 판단의 정정이나 반복 마찰의 예방 구조가 핵심이면 주 역할이 아닙니다. + +`$calibrate-judgment`는 사용자가 이전 판단의 기준·범위·의미를 명시적으로 정정했을 때 활성화됩니다. 정정을 현재 판단에 반영하며, 반복 방지 구조를 직접 설계하지는 않습니다. + +`$friction-distillation`은 반복되는 마찰에 대해 재발 방지 개입이 필요한지와 대응 수준을 판단합니다. 반복 자체를 증거로 과장하지 않고, 현재 비용과 실행 가능성에 맞춰 선택합니다. + +## Calibrate 후 Friction 조합 + +명시적 정정과 재발 방지 판단을 함께 요청하면 `$calibrate-judgment`가 먼저 정정된 현재 판단과 전제를 제공하고, `$friction-distillation`이 이어서 재사용 가능한 개입을 판단합니다. + +순서가 중요합니다. calibrate는 이번 판단의 의미를 바로잡고, friction은 그 정정 또는 유사 조건에서 재발을 줄일 최소 대응을 선택합니다. + +## SemVer 의미 + +Patch는 기존 activation 경계와 composition 순서를 깨지 않는 호환 가능한 문구·설명·검증·문서 수정입니다. + +Minor는 스킬 추가·삭제, 중요한 trigger 변경, starter prompt 변경, 또는 calibrate와 friction 사이의 composition 역할 변경입니다. + +Major 후보는 canonical 패키지 계약, 설치 경로, marketplace 공개 구조, 기존 스킬 호출 계약을 호환 불가능하게 바꾸는 변경입니다. diff --git a/scripts/validate_plugin.py b/scripts/validate_plugin.py new file mode 100644 index 0000000..c33d0ab --- /dev/null +++ b/scripts/validate_plugin.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Validate the canonical Judgment Craft plugin package.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +EXPECTED_SKILLS = { + "practical-judgment", + "calibrate-judgment", + "friction-distillation", +} +REQUIRED_AGENT_FIELDS = ("display_name", "short_description", "default_prompt") +SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$") +RELEASE_TAG_RE = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +LOCAL_MD_LINK_RE = re.compile(r"(? int: + parser = argparse.ArgumentParser() + parser.add_argument("--release-tag", help="Release tag to compare with the manifest version.") + args = parser.parse_args(argv) + + repo_root = Path(__file__).resolve().parents[1] + validator = Validator(repo_root) + validator.run(args.release_tag) + return validator.report() + + +class Validator: + def __init__(self, repo_root: Path) -> None: + self.repo_root = repo_root + self.plugin_root = repo_root / "plugins" / "judgment-craft" + self.manifest_path = self.plugin_root / ".codex-plugin" / "plugin.json" + self.errors: list[str] = [] + self.manifest: dict[str, object] = {} + + def run(self, release_tag: str | None) -> None: + self._load_manifest() + self._validate_manifest() + self._validate_skills() + self._validate_markdown_links() + self._validate_changelogs() + self._validate_release_tag(release_tag) + self._validate_no_plugin_symlinks() + + def report(self) -> int: + if not self.errors: + print("Validation passed.") + return 0 + for error in self.errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + def _add(self, message: str) -> None: + self.errors.append(message) + + def _load_manifest(self) -> None: + if not self.manifest_path.is_file(): + self._add("manifest missing: plugins/judgment-craft/.codex-plugin/plugin.json") + return + try: + data = json.loads(self.manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + self._add(f"manifest JSON invalid: {exc}") + return + if not isinstance(data, dict): + self._add("manifest root must be an object") + return + self.manifest = data + + def _validate_manifest(self) -> None: + manifest = self.manifest + if not manifest: + return + + if manifest.get("name") != "judgment-craft": + self._add("manifest name must be exactly 'judgment-craft'") + + version = manifest.get("version") + if not isinstance(version, str) or not SEMVER_RE.fullmatch(version): + self._add("manifest version must be strict SemVer without build metadata") + + description = manifest.get("description") + if not isinstance(description, str) or not description.strip(): + self._add("manifest description must be a nonempty string") + + if manifest.get("skills") != "./skills/": + self._add("manifest skills must be exactly './skills/'") + + if manifest.get("license") != "MIT": + self._add("manifest license must be MIT") + + repository = manifest.get("repository") + if not isinstance(repository, str) or not repository.startswith("https://"): + self._add("manifest repository must be an https URL") + + for forbidden in ("hooks", "apps", "mcpServers"): + if forbidden in manifest: + self._add(f"manifest must not define {forbidden}") + + skills_path = manifest.get("skills") + if isinstance(skills_path, str): + resolved = (self.plugin_root / skills_path).resolve() + try: + resolved.relative_to(self.plugin_root.resolve()) + except ValueError: + self._add("manifest skills path escapes plugin root") + if not resolved.is_dir(): + self._add("manifest skills path does not exist") + + interface = manifest.get("interface") + if not isinstance(interface, dict): + self._add("manifest interface must be an object") + return + + for field in ("displayName", "shortDescription", "developerName", "category"): + value = interface.get(field) + if not isinstance(value, str) or not value.strip(): + self._add(f"manifest interface.{field} must be a nonempty string") + + prompts = interface.get("defaultPrompt") + if not isinstance(prompts, list) or not 1 <= len(prompts) <= 3: + self._add("manifest interface.defaultPrompt must contain 1 to 3 strings") + prompt_texts: list[str] = [] + else: + prompt_texts = [] + for idx, prompt in enumerate(prompts, start=1): + if not isinstance(prompt, str) or not prompt.strip(): + self._add(f"manifest interface.defaultPrompt item {idx} must be a nonempty string") + elif "$" not in prompt: + self._add(f"manifest interface.defaultPrompt item {idx} must reference a skill token") + if isinstance(prompt, str): + prompt_texts.append(prompt) + + combined = "\n".join(prompt_texts) + for skill in sorted(EXPECTED_SKILLS): + token = f"${skill}" + if not has_skill_token(combined, skill): + self._add(f"manifest interface.defaultPrompt must reference {token}") + + def _validate_skills(self) -> None: + skills_root = self.plugin_root / "skills" + if not skills_root.is_dir(): + self._add("skills directory missing") + return + + actual = {path.name for path in skills_root.iterdir() if path.is_dir()} + missing = sorted(EXPECTED_SKILLS - actual) + extra = sorted(actual - EXPECTED_SKILLS) + if missing: + self._add(f"missing skill directories: {', '.join(missing)}") + if extra: + self._add(f"unexpected skill directories: {', '.join(extra)}") + + for skill_name in sorted(EXPECTED_SKILLS): + skill_dir = skills_root / skill_name + if not skill_dir.is_dir(): + continue + self._validate_skill_markdown(skill_dir, skill_name) + self._validate_agent_metadata(skill_dir, skill_name) + + def _validate_skill_markdown(self, skill_dir: Path, skill_name: str) -> None: + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + self._add(f"{skill_name}: SKILL.md missing") + return + frontmatter = parse_frontmatter(skill_md.read_text(encoding="utf-8", errors="replace")) + if frontmatter is None: + self._add(f"{skill_name}: SKILL.md frontmatter missing") + return + if frontmatter.get("name") != skill_name: + self._add(f"{skill_name}: SKILL.md frontmatter name must match directory") + description = frontmatter.get("description") + if not isinstance(description, str) or not description.strip(): + self._add(f"{skill_name}: SKILL.md frontmatter description must be nonempty text") + + def _validate_agent_metadata(self, skill_dir: Path, skill_name: str) -> None: + agent_path = skill_dir / "agents" / "openai.yaml" + if not agent_path.is_file(): + self._add(f"{skill_name}: agents/openai.yaml missing") + return + data = parse_simple_yaml(agent_path.read_text(encoding="utf-8", errors="replace")) + interface = data.get("interface") + if not isinstance(interface, dict): + self._add(f"{skill_name}: agents/openai.yaml interface missing") + return + for field in REQUIRED_AGENT_FIELDS: + value = interface.get(field) + if not isinstance(value, str) or not value.strip(): + self._add(f"{skill_name}: agents/openai.yaml interface.{field} missing") + default_prompt = interface.get("default_prompt") + token = f"${skill_name}" + if isinstance(default_prompt, str) and not has_skill_token(default_prompt, skill_name): + self._add(f"{skill_name}: agents/openai.yaml default_prompt must reference {token}") + + def _validate_markdown_links(self) -> None: + for path in tracked_or_current_markdown_files(self.repo_root): + text = path.read_text(encoding="utf-8", errors="replace") + for raw_target in LOCAL_MD_LINK_RE.findall(text): + target = raw_target.strip() + if not target or target.startswith(IGNORED_LINK_PREFIXES): + continue + target = target.split("#", 1)[0].strip() + if not target: + continue + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target): + continue + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + target_path = (path.parent / target).resolve() + try: + target_path.relative_to(self.repo_root.resolve()) + except ValueError: + self._add(f"{display_path(path, self.repo_root)}: markdown link escapes repo: {raw_target}") + continue + if not target_path.exists(): + self._add(f"{display_path(path, self.repo_root)}: markdown link target missing: {raw_target}") + + def _validate_changelogs(self) -> None: + version = self.manifest.get("version") + if not isinstance(version, str): + return + for changelog_name in ("CHANGELOG.md", "CHANGELOG.en.md"): + path = self.repo_root / changelog_name + if not path.is_file(): + self._add(f"{changelog_name} missing") + continue + text = path.read_text(encoding="utf-8", errors="replace") + if not re.search(rf"^##\s+{re.escape(version)}(?:\s+-|\s*$)", text, re.MULTILINE): + self._add(f"{changelog_name} must contain a heading for version {version}") + + def _validate_release_tag(self, release_tag: str | None) -> None: + if release_tag is None: + return + version = self.manifest.get("version") + if not isinstance(version, str): + return + if "+" in release_tag or not RELEASE_TAG_RE.fullmatch(release_tag): + self._add("--release-tag must be vX.Y.Z and must not include build metadata") + return + if release_tag != f"v{version}": + self._add(f"--release-tag {release_tag} does not match manifest version {version}") + + def _validate_no_plugin_symlinks(self) -> None: + if not self.plugin_root.exists(): + return + for root, dirs, files in os.walk(self.plugin_root): + for name in [*dirs, *files]: + path = Path(root) / name + try: + is_link = path.is_symlink() + except OSError: + continue + if is_link: + self._add(f"symlink not allowed under plugin root: {display_path(path, self.repo_root)}") + + +def parse_frontmatter(text: str) -> dict[str, str] | None: + if not text.startswith("---"): + return None + lines = text.splitlines() + if not lines or lines[0] != "---": + return None + for index, line in enumerate(lines[1:], start=1): + if line == "---": + return parse_key_values("\n".join(lines[1:index])) + return None + + +def parse_simple_yaml(text: str) -> dict[str, object]: + root: dict[str, object] = {} + current_map: dict[str, str] | None = None + for raw_line in text.splitlines(): + line = raw_line.rstrip() + if not line.strip() or line.lstrip().startswith("#"): + continue + if not line.startswith(" ") and line.endswith(":"): + key = line[:-1].strip() + current_map = {} + root[key] = current_map + continue + if current_map is not None and line.startswith(" "): + key, value = split_yaml_pair(line.strip()) + if key: + current_map[key] = unquote(value) + continue + key, value = split_yaml_pair(line) + if key: + root[key] = unquote(value) + current_map = None + return root + + +def parse_key_values(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw_line in text.splitlines(): + key, value = split_yaml_pair(raw_line.strip()) + if key: + data[key] = unquote(value) + return data + + +def split_yaml_pair(line: str) -> tuple[str, str]: + if ":" not in line: + return "", "" + key, value = line.split(":", 1) + return key.strip(), value.strip() + + +def unquote(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def has_skill_token(text: str, skill_name: str) -> bool: + token = re.escape(f"${skill_name}") + return re.search(rf"(? list[Path]: + files: set[Path] = set() + try: + result = subprocess.run( + ["git", "ls-files", "--", "*.md"], + cwd=repo_root, + text=True, + capture_output=True, + check=False, + ) + except OSError: + result = None + if result and result.returncode == 0: + files.update(repo_root / line for line in result.stdout.splitlines() if line.strip()) + + files.update(path for path in repo_root.rglob("*.md") if ".git" not in path.parts) + return sorted(path for path in files if path.is_file()) + + +def display_path(path: Path, repo_root: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_plugin.py b/tests/test_validate_plugin.py new file mode 100644 index 0000000..59a9a98 --- /dev/null +++ b/tests/test_validate_plugin.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class ValidatePluginTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.fixture = Path(self.temp_dir.name) / "repo" + ignore = shutil.ignore_patterns(".git", "__pycache__", "*.pyc") + shutil.copytree(REPO_ROOT, self.fixture, ignore=ignore) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def run_validator(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(self.fixture / "scripts" / "validate_plugin.py"), *args], + cwd=self.fixture, + text=True, + capture_output=True, + check=False, + ) + + def manifest(self) -> dict[str, object]: + path = self.fixture / "plugins" / "judgment-craft" / ".codex-plugin" / "plugin.json" + return json.loads(path.read_text(encoding="utf-8")) + + def write_manifest(self, data: dict[str, object]) -> None: + path = self.fixture / "plugins" / "judgment-craft" / ".codex-plugin" / "plugin.json" + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def test_committed_repository_passes(self) -> None: + result = self.run_validator() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Validation passed.", result.stdout) + + def test_extra_and_missing_skill_rejected(self) -> None: + skills = self.fixture / "plugins" / "judgment-craft" / "skills" + shutil.move(skills / "practical-judgment", skills / "practical-judgment.bak") + (skills / "extra-skill").mkdir() + result = self.run_validator() + self.assertEqual(result.returncode, 1) + self.assertIn("missing skill directories: practical-judgment", result.stderr) + self.assertIn("unexpected skill directories: extra-skill, practical-judgment.bak", result.stderr) + + def test_frontmatter_name_mismatch_rejected(self) -> None: + path = self.fixture / "plugins" / "judgment-craft" / "skills" / "practical-judgment" / "SKILL.md" + text = path.read_text(encoding="utf-8").replace("name: practical-judgment", "name: wrong-name") + path.write_text(text, encoding="utf-8") + result = self.run_validator() + self.assertEqual(result.returncode, 1) + self.assertIn("SKILL.md frontmatter name must match directory", result.stderr) + + def test_missing_agent_metadata_rejected(self) -> None: + path = self.fixture / "plugins" / "judgment-craft" / "skills" / "calibrate-judgment" / "agents" / "openai.yaml" + path.write_text( + 'interface:\n display_name: "Calibrate Judgment"\n default_prompt: "Use $calibrate-judgment."\n', + encoding="utf-8", + ) + result = self.run_validator() + self.assertEqual(result.returncode, 1) + self.assertIn("calibrate-judgment: agents/openai.yaml interface.short_description missing", result.stderr) + + def test_starter_prompts_missing_a_skill_rejected(self) -> None: + data = self.manifest() + interface = data["interface"] + assert isinstance(interface, dict) + interface["defaultPrompt"] = ["Use $practical-judgment.", "Use $calibrate-judgment."] + self.write_manifest(data) + result = self.run_validator() + self.assertEqual(result.returncode, 1) + self.assertIn("manifest interface.defaultPrompt must reference $friction-distillation", result.stderr) + + def test_invalid_and_mismatched_release_tag_rejected(self) -> None: + invalid = self.run_validator("--release-tag", "v0.1.0+build") + self.assertEqual(invalid.returncode, 1) + self.assertIn("must not include build metadata", invalid.stderr) + + prerelease = self.run_validator("--release-tag", "v0.1.0-rc.1") + self.assertEqual(prerelease.returncode, 1) + self.assertIn("must be vX.Y.Z", prerelease.stderr) + + mismatched = self.run_validator("--release-tag", "v0.1.1") + self.assertEqual(mismatched.returncode, 1) + self.assertIn("does not match manifest version 0.1.0", mismatched.stderr) + + def test_broken_and_escaping_relative_markdown_link_rejected(self) -> None: + readme = self.fixture / "README.md" + readme.write_text( + readme.read_text(encoding="utf-8") + + "\n[broken](docs/MISSING.md)\n[escape](../outside.md)\n", + encoding="utf-8", + ) + result = self.run_validator() + self.assertEqual(result.returncode, 1) + self.assertIn("markdown link target missing: docs/MISSING.md", result.stderr) + self.assertIn("markdown link escapes repo: ../outside.md", result.stderr) + + def test_cli_aggregates_errors_and_exits_1(self) -> None: + data = self.manifest() + data["name"] = "wrong" + data["hooks"] = {} + self.write_manifest(data) + result = self.run_validator("--release-tag", "v9.9.9") + self.assertEqual(result.returncode, 1) + self.assertIn("manifest name must be exactly 'judgment-craft'", result.stderr) + self.assertIn("manifest must not define hooks", result.stderr) + self.assertIn("--release-tag v9.9.9 does not match manifest version 0.1.0", result.stderr) + + +if __name__ == "__main__": + unittest.main()