diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..4f6113a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" + +bash "$ROOT_DIR/scripts/build-runtime-plugins.sh" --check + +generated_paths='^(plugins/ndf-claude|plugins/ndf-codex|plugins/ndf-kiro|plugins/mcp/claude|plugins/mcp/codex|plugins/mcp/kiro)/' +unstaged_generated="$(git -C "$ROOT_DIR" diff --name-only | grep -E "$generated_paths" || true)" +if [ -n "$unstaged_generated" ]; then + echo "Generated runtime files have unstaged changes. Run build and git add the generated files:" >&2 + echo "$unstaged_generated" >&2 + exit 1 +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..f46772e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" + +bash "$ROOT_DIR/scripts/validate-runtime-plugins.sh" diff --git a/.github/workflows/runtime-plugin-validate.yml b/.github/workflows/runtime-plugin-validate.yml new file mode 100644 index 0000000..2b6a2cd --- /dev/null +++ b/.github/workflows/runtime-plugin-validate.yml @@ -0,0 +1,67 @@ +name: Runtime plugin validation + +on: + pull_request: + paths: + - ".agents/**" + - ".claude-plugin/**" + - ".github/workflows/runtime-plugin-validate.yml" + - ".githooks/**" + - "AGENTS.md" + - "CLAUDE.md" + - "KIRO.md" + - "README.md" + - "docs/**" + - "plugins/**" + - "scripts/**" + push: + branches: + - main + - "release/**" + paths: + - ".agents/**" + - ".claude-plugin/**" + - ".github/workflows/runtime-plugin-validate.yml" + - ".githooks/**" + - "AGENTS.md" + - "CLAUDE.md" + - "KIRO.md" + - "README.md" + - "docs/**" + - "plugins/**" + - "scripts/**" + +jobs: + runtime-plugin-build-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: bash scripts/build-runtime-plugins.sh --check + + runtime-plugin-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: npm install -g @anthropic-ai/claude-code + - run: bash scripts/validate-runtime-plugins.sh + + markdown-link-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: python3 scripts/check-markdown-links.py --root . diff --git a/README.md b/README.md index 4e4687d..6cc757a 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ ai-plugins/ └── CLAUDE.md # AIエージェント向けガイドライン ``` +#### Runtime plugin の検証 + +共通ソースや runtime 配布物を変更した場合は、生成物同期と manifest / link 検証を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/validate-runtime-plugins.sh +``` + +ローカル hook を使う場合は以下を実行します。 + +```bash +bash scripts/install-dev-hooks.sh +``` + #### 新しいプラグインの作成手順 **1. プラグインディレクトリを作成:** diff --git a/docs/ndf-plugin-reference.md b/docs/ndf-plugin-reference.md index 5bd37e3..b6acf78 100644 --- a/docs/ndf-plugin-reference.md +++ b/docs/ndf-plugin-reference.md @@ -82,12 +82,15 @@ Claude Code と Codex は marketplace から同じ plugin 名で install しま ```bash bash scripts/build-runtime-plugins.sh bash scripts/build-runtime-plugins.sh --check +bash scripts/validate-runtime-plugins.sh claude plugin validate plugins/ndf-claude python3 -m json.tool plugins/ndf-codex/.codex-plugin/plugin.json >/dev/null bash plugins/ndf-kiro/install.sh --help ``` -`--check` は `plugins/ndf-*` と `plugins/mcp/claude|codex|kiro` の生成物が共通編集元と同期していることを検証します。 +`--check` は `plugins/ndf-*` と `plugins/mcp/claude|codex|kiro` の生成物が共通編集元と同期していることを検証します。`validate-runtime-plugins.sh` は生成物同期、JSON / manifest、marketplace source、Kiro installer、Markdown ローカルリンクをまとめて確認します。 + +ローカル hook を使う場合は `bash scripts/install-dev-hooks.sh` で `.githooks/` を有効化します。 ## 外部 AI 委譲 diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 4317e90..e81ed49 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -141,6 +141,21 @@ plugins/{plugin-name}/ /plugin install {plugin-name}@ai-plugins ``` +### Runtime plugin 検証 + +NDF / MCP の runtime 別配布物を変更した場合は、以下を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/validate-runtime-plugins.sh +``` + +ローカル hook は任意で導入できます。 + +```bash +bash scripts/install-dev-hooks.sh +``` + ### 検証チェックリスト - [ ] marketplace.jsonが正しい形式 diff --git a/issues/agent-runtime-plugin-split.md b/issues/agent-runtime-plugin-split.md index a4e13dc..e7f1547 100644 --- a/issues/agent-runtime-plugin-split.md +++ b/issues/agent-runtime-plugin-split.md @@ -549,17 +549,17 @@ base branch: `main` | merge 済み | #47 | `feature/runtime-split-claude` | Task 2: `plugins/ndf-claude` を追加し、Claude marketplace を切替 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | | merge 済み | #48 | `feature/runtime-split-codex` | Task 3: `plugins/ndf-codex` を追加し、Codex marketplace を切替 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | | merge 済み | #49 | `feature/runtime-split-kiro` | Task 4: `plugins/ndf-kiro` を作成し、Kiro installer / docs / templates を移動 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | -| 実装中 | #50 | `feature/runtime-split-docs-cleanup` | Task 5: README / AGENTS / docs / specs 更新、旧 `plugins/ndf` の削除 | 旧 monolithic plugin directory を削除し、docs / README / AGENTS を runtime 分離後の構成へ更新中 | +| merge 済み | #50 | `feature/runtime-split-docs-cleanup` | Task 5: README / AGENTS / docs / specs 更新、旧 `plugins/ndf` の削除 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。merge 後検証済み | | merge 済み | #51 / #54 | `feature/runtime-split-mcp-plugins` | Task 6: MCP plugin を runtime 別に分離 | #51 はローテーションで close。#54 が cross-review approved、`release/runtime-plugin-split` へ merge 済み | -| 未着手 | #52 | `feature/runtime-split-validation` | Task 7: validate script、dev hook、CI、リンク検証 | #50 merge 後に実施 | +| 実装中 | #52 | `feature/runtime-split-validation` | Task 7: validate script、dev hook、CI、リンク検証 | `validate-runtime-plugins.sh`、`.githooks/`、GitHub Actions、Markdown link check を追加。ローカル検証通過 | | 未着手 | #53 | `feature/runtime-split-container-smoke` | Task 8: runtime smoke test | PR7 merge 後 | 再開時の前提: -- 現在の作業ブランチは `feature/runtime-split-docs-cleanup`。 -- `release/runtime-plugin-split` には PR #46 / #47 / #48 / #49 / #54 が merge 済み。 -- 次は PR #50 の docs cleanup を push し、cross-review 後に `release/runtime-plugin-split` へ merge する。 -- PR #50 merge 後に PR #52 `feature/runtime-split-validation`、PR #53 `feature/runtime-split-container-smoke` の順で進める。 +- 現在の作業ブランチは `feature/runtime-split-validation`。 +- `release/runtime-plugin-split` には PR #46 / #47 / #48 / #49 / #50 / #54 が merge 済み。 +- 次は PR #52 の validation 実装を push し、cross-review 後に `release/runtime-plugin-split` へ merge する。 +- PR #52 merge 後に PR #53 `feature/runtime-split-container-smoke` を進める。 ## 影響範囲 diff --git a/scripts/check-markdown-links.py b/scripts/check-markdown-links.py new file mode 100755 index 0000000..5dd6bc9 --- /dev/null +++ b/scripts/check-markdown-links.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Check repository-relative Markdown links. + +External URLs, mailto links, pure anchors, and absolute filesystem paths are +ignored. This keeps the check stable in CI while still catching broken links +between repository documents. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlparse + + +LINK_RE = re.compile(r"(?]*href=[\"']([^\"']+)[\"']", re.IGNORECASE) +TITLE_RE = re.compile(r"\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\))\s*$") + + +def iter_markdown_files(root: Path) -> list[Path]: + roots = [root / "README.md", root / "AGENTS.md", root / "CLAUDE.md", root / "KIRO.md", root / "docs", root / "plugins"] + files: list[Path] = [] + for item in roots: + if item.is_file(): + files.append(item) + elif item.is_dir(): + files.extend(item.rglob("*.md")) + return sorted(set(files)) + + +def strip_title(target: str) -> str: + target = target.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1].strip() + return TITLE_RE.sub("", target) + + +def should_skip(target: str) -> bool: + if not target or target.startswith("#"): + return True + parsed = urlparse(target) + if parsed.scheme or target.startswith("//"): + return True + if target.startswith("/"): + return True + if "{" in target or "}" in target: + return True + return False + + +def target_path(root: Path, source: Path, raw_target: str) -> Path | None: + target = strip_title(raw_target) + if should_skip(target): + return None + path_part = target.split("#", 1)[0] + if not path_part: + return None + path_part = unquote(path_part) + return (source.parent / path_part).resolve() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".", help="repository root") + args = parser.parse_args() + + root = Path(args.root).resolve() + failures: list[str] = [] + + for md in iter_markdown_files(root): + raw_lines = md.read_text(encoding="utf-8").splitlines() + filtered: list[str] = [] + in_fence = False + for line in raw_lines: + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if in_fence or line.lstrip().startswith(">"): + continue + filtered.append(line) + text = "\n".join(filtered) + targets = [m.group(1) for m in LINK_RE.finditer(text)] + targets.extend(m.group(1) for m in INLINE_HTML_RE.finditer(text)) + for raw in targets: + resolved = target_path(root, md, raw) + if resolved is None: + continue + try: + resolved.relative_to(root) + except ValueError: + failures.append(f"{md.relative_to(root)}: link escapes repository: {raw}") + continue + if not resolved.exists(): + failures.append(f"{md.relative_to(root)}: missing link target: {raw}") + + if failures: + print("Markdown link check failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + + print("Markdown local links are valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install-dev-hooks.sh b/scripts/install-dev-hooks.sh new file mode 100755 index 0000000..1a4830f --- /dev/null +++ b/scripts/install-dev-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ ! -d "$ROOT_DIR/.git" ] && ! git -C "$ROOT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "ERROR: not a git repository: $ROOT_DIR" >&2 + exit 1 +fi + +git -C "$ROOT_DIR" config core.hooksPath .githooks +echo "Installed development hooks: core.hooksPath=.githooks" diff --git a/scripts/validate-runtime-plugins.sh b/scripts/validate-runtime-plugins.sh new file mode 100755 index 0000000..cd7e2af --- /dev/null +++ b/scripts/validate-runtime-plugins.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +run() { + echo "==> $*" + "$@" +} + +run bash "$ROOT_DIR/scripts/build-runtime-plugins.sh" --check + +run python3 -m json.tool "$ROOT_DIR/.claude-plugin/marketplace.json" >/dev/null +run python3 -m json.tool "$ROOT_DIR/.agents/plugins/marketplace.json" >/dev/null + +while IFS= read -r manifest; do + run python3 -m json.tool "$manifest" >/dev/null +done < <(find "$ROOT_DIR/plugins" -path '*/.claude-plugin/plugin.json' -o -path '*/.codex-plugin/plugin.json' | sort) + +while IFS= read -r mcp_config; do + run python3 -m json.tool "$mcp_config" >/dev/null +done < <(find "$ROOT_DIR/plugins/mcp" -name .mcp.json | sort) + +run python3 - "$ROOT_DIR" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) + +def read_json(path: Path): + with path.open(encoding="utf-8") as fh: + return json.load(fh) + +errors: list[str] = [] + +claude_marketplace = read_json(root / ".claude-plugin/marketplace.json") +for plugin in claude_marketplace.get("plugins", []): + source = plugin.get("source") + if not isinstance(source, str): + errors.append(f".claude-plugin marketplace plugin {plugin.get('name')} has invalid source") + continue + plugin_dir = (root / source).resolve() + if not plugin_dir.is_dir(): + errors.append(f"Claude marketplace source missing: {source}") + continue + if not (plugin_dir / ".claude-plugin/plugin.json").is_file(): + errors.append(f"Claude plugin manifest missing under {source}") + +codex_marketplace = read_json(root / ".agents/plugins/marketplace.json") +for plugin in codex_marketplace.get("plugins", []): + source = plugin.get("source", {}) + source_path = source.get("path") if isinstance(source, dict) else None + if not isinstance(source_path, str): + errors.append(f"Codex marketplace plugin {plugin.get('name')} has invalid source.path") + continue + plugin_dir = (root / source_path).resolve() + if not plugin_dir.is_dir(): + errors.append(f"Codex marketplace source missing: {source_path}") + continue + if not (plugin_dir / ".codex-plugin/plugin.json").is_file(): + errors.append(f"Codex plugin manifest missing under {source_path}") + +for manifest in (root / "plugins/ndf-shared/manifests").glob("*-skills.txt"): + runtime = manifest.name.removesuffix("-skills.txt") + runtime_skills = root / f"plugins/ndf-{runtime}/skills" + if not runtime_skills.is_dir(): + errors.append(f"runtime skills directory missing: {runtime_skills.relative_to(root)}") + continue + for raw in manifest.read_text(encoding="utf-8").splitlines(): + skill = raw.split("#", 1)[0].strip() + if not skill: + continue + if "/" in skill or ".." in skill: + errors.append(f"invalid skill name in {manifest.relative_to(root)}: {skill}") + continue + if not (root / "plugins/ndf-shared/skills" / skill / "SKILL.md").is_file(): + errors.append(f"shared skill missing: {skill}") + if not (runtime_skills / skill / "SKILL.md").is_file(): + errors.append(f"{runtime} runtime skill missing: {skill}") + +for mcp in sorted((root / "plugins/mcp/shared").iterdir()): + if not mcp.is_dir(): + continue + for runtime in ("claude", "codex", "kiro"): + runtime_dir = root / "plugins/mcp" / runtime / mcp.name + if not runtime_dir.is_dir(): + errors.append(f"MCP runtime directory missing: plugins/mcp/{runtime}/{mcp.name}") + continue + if not (runtime_dir / ".mcp.json").is_file(): + errors.append(f"MCP config missing: plugins/mcp/{runtime}/{mcp.name}/.mcp.json") + if runtime == "kiro" and not (runtime_dir / "install.sh").is_file(): + errors.append(f"Kiro MCP installer missing: plugins/mcp/kiro/{mcp.name}/install.sh") + +if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) + +print("runtime plugin manifests and generated paths are valid") +PY + +if command -v claude >/dev/null 2>&1; then + run claude plugin validate "$ROOT_DIR/plugins/ndf-claude" + run claude plugin validate "$ROOT_DIR/.claude-plugin/marketplace.json" +else + echo "==> claude CLI not found; skipped claude plugin validate" +fi + +run bash "$ROOT_DIR/plugins/ndf-kiro/install.sh" --dry-run >/dev/null +run bash "$ROOT_DIR/plugins/ndf-kiro/install.sh" --dry-run --with-codex >/dev/null + +while IFS= read -r installer; do + run bash "$installer" --dry-run >/dev/null +done < <(find "$ROOT_DIR/plugins/mcp/kiro" -name install.sh | sort) + +run python3 "$ROOT_DIR/scripts/check-markdown-links.py" --root "$ROOT_DIR" + +echo "runtime plugin validation passed"