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()