#!/usr/bin/env bash # validate-plugin.sh — Foundation plugin structure validator for claude-design # Usage: bash tests/validate-plugin.sh # Exit codes: 0 = all checks pass; 1 = at least one FAIL # # Forked from plugins/ms-ai-architect/tests/validate-plugin.sh: # keep: helpers (pass/fail/warn), counters, PLUGIN_ROOT, JSON-validity check, # README/CLAUDE.md existence checks # strip: agent frontmatter loop, commands frontmatter loop, KB-staleness checks, # architect:* command-name assertions, references-count assertions # add: SKILL.md frontmatter + description-length, LICENSE content, GOVERNANCE # existence, .coverage.md existence, forbidden-command-name regex (h), # operator-private-context grep (i), Norwegian-leakage grep (j) set -euo pipefail LC_ALL=en_US.UTF-8 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" PASS=0 FAIL=0 WARN=0 pass() { printf "${GREEN} ✓ %s${NC}\n" "$1"; PASS=$((PASS + 1)); } fail() { printf "${RED} ✗ %s${NC}\n" "$1"; FAIL=$((FAIL + 1)); } warn() { printf "${YELLOW} ⚠ %s${NC}\n" "$1"; WARN=$((WARN + 1)); } echo "=== claude-design Plugin Validation ===" echo "Plugin root: $PLUGIN_ROOT" echo "" # ------------------------------------------------------- # Check (a): plugin.json valid + required fields # ------------------------------------------------------- echo "--- (a) plugin.json structure ---" PLUGIN_JSON="$PLUGIN_ROOT/.claude-plugin/plugin.json" if [ ! -f "$PLUGIN_JSON" ]; then fail ".claude-plugin/plugin.json missing" else if node -e "JSON.parse(require('fs').readFileSync('$PLUGIN_JSON'))" 2>/dev/null; then pass ".claude-plugin/plugin.json is valid JSON" else fail ".claude-plugin/plugin.json is invalid JSON" fi for field in name version description; do if node -e "const p = JSON.parse(require('fs').readFileSync('$PLUGIN_JSON')); if (typeof p['$field'] !== 'string' || p['$field'] === '') process.exit(1)" 2>/dev/null; then pass "plugin.json has '$field'" else fail "plugin.json missing or empty '$field'" fi done fi echo "" # ------------------------------------------------------- # Check (b): at least one SKILL.md under skills/*/ # ------------------------------------------------------- echo "--- (b) SKILL.md presence ---" SKILL_COUNT=0 for skill_file in "$PLUGIN_ROOT"/skills/*/SKILL.md; do [ -f "$skill_file" ] || continue SKILL_COUNT=$((SKILL_COUNT + 1)) done if [ "$SKILL_COUNT" -ge 1 ]; then pass "found $SKILL_COUNT SKILL.md file(s) under skills/*/" else fail "no SKILL.md found under skills/*/" fi echo "" # ------------------------------------------------------- # Check (c): SKILL.md frontmatter has name+description, description >=400 chars # ------------------------------------------------------- echo "--- (c) SKILL.md frontmatter quality ---" for skill_file in "$PLUGIN_ROOT"/skills/*/SKILL.md; do [ -f "$skill_file" ] || continue basename_skill="$(basename "$(dirname "$skill_file")")/SKILL.md" first_line="$(head -n 1 "$skill_file")" if [ "$first_line" != "---" ]; then fail "$basename_skill: missing frontmatter delimiter on line 1" continue fi frontmatter="$(awk 'NR==1{next} /^---$/{exit} {print}' "$skill_file")" if echo "$frontmatter" | grep -qE '^name:'; then pass "$basename_skill: has 'name:'" else fail "$basename_skill: missing 'name:'" fi if echo "$frontmatter" | grep -qE '^description:'; then pass "$basename_skill: has 'description:'" else fail "$basename_skill: missing 'description:'" fi desc_len="$(awk '/^description: \|/,/^---$/' "$skill_file" | wc -c | tr -d '[:space:]')" if [ -z "$desc_len" ]; then desc_len=0; fi if [ "$desc_len" -ge 400 ]; then pass "$basename_skill: description block is $desc_len chars (>=400)" else fail "$basename_skill: description block is $desc_len chars (<400)" fi done echo "" # ------------------------------------------------------- # Check (d): LICENSE exists, non-empty, contains "MIT License" # ------------------------------------------------------- echo "--- (d) LICENSE ---" LICENSE_FILE="$PLUGIN_ROOT/LICENSE" if [ ! -f "$LICENSE_FILE" ]; then fail "LICENSE missing" elif [ ! -s "$LICENSE_FILE" ]; then fail "LICENSE is empty" elif ! grep -q "MIT License" "$LICENSE_FILE"; then fail "LICENSE does not contain 'MIT License'" else pass "LICENSE exists, non-empty, MIT License" fi echo "" # ------------------------------------------------------- # Check (e): GOVERNANCE.md exists, non-empty # ------------------------------------------------------- echo "--- (e) GOVERNANCE.md ---" GOVERNANCE_FILE="$PLUGIN_ROOT/GOVERNANCE.md" if [ ! -f "$GOVERNANCE_FILE" ]; then fail "GOVERNANCE.md missing" elif [ ! -s "$GOVERNANCE_FILE" ]; then fail "GOVERNANCE.md is empty" else pass "GOVERNANCE.md exists, non-empty" fi echo "" # ------------------------------------------------------- # Check (f): README.md + CLAUDE.md exist, non-empty # ------------------------------------------------------- echo "--- (f) README.md and CLAUDE.md ---" for f in README.md CLAUDE.md; do fpath="$PLUGIN_ROOT/$f" if [ ! -f "$fpath" ]; then fail "$f missing" elif [ ! -s "$fpath" ]; then fail "$f is empty" else pass "$f exists, non-empty" fi done echo "" # ------------------------------------------------------- # Check (g): .coverage.md exists at plugin root # ------------------------------------------------------- echo "--- (g) .coverage.md ---" COVERAGE_FILE="$PLUGIN_ROOT/.coverage.md" if [ ! -f "$COVERAGE_FILE" ]; then fail ".coverage.md missing" elif [ ! -s "$COVERAGE_FILE" ]; then fail ".coverage.md is empty" else pass ".coverage.md exists, non-empty" fi echo "" # ------------------------------------------------------- # Check (h): forbidden command-name regex (scope fence vs # Anthropic's knowledge-work-plugins/design) # ------------------------------------------------------- echo "--- (h) forbidden command-name regex ---" FORBIDDEN_REGEX='^name:[[:space:]]*(claude-design:)?(critique|accessibility|ux-copy|research-synthesis|design-system|handoff)[[:space:]]*$' H_HIT=0 for cmd_file in "$PLUGIN_ROOT"/commands/*.md "$PLUGIN_ROOT"/skills/*/SKILL.md; do [ -f "$cmd_file" ] || continue if grep -qE "$FORBIDDEN_REGEX" "$cmd_file"; then fail "command-name collision with Anthropic's official knowledge-work-plugins/design plugin: $cmd_file" H_HIT=$((H_HIT + 1)) fi done if [ "$H_HIT" -eq 0 ]; then pass "no forbidden command-name collisions" fi echo "" # ------------------------------------------------------- # Shipped-content enumeration (shared by checks (i) and (j)) # # "Shipped" is derived from git, not from a list of file names: any file git # ignores is session state that never leaves this machine, so it is not shipped # content. A name list cannot hold this line -- it already rotted once, when the # STATE.md convention replaced the three local-only files it knows about, and # the resulting false positive was self-reproducing (any note explaining the # failure re-triggered it). # # Derived from `git check-ignore` rather than `git ls-files` on purpose: an # untracked Markdown file that is NOT ignored is about to ship and must stay in # scope, so new content is checked before it is ever staged. # # When git cannot answer (no repo, no git binary) every file is treated as # shipped: conservative, so these checks can still fail rather than silently # passing on an empty file list. # ------------------------------------------------------- echo "--- shipped-content enumeration (git-derived) ---" GIT_AVAILABLE=1 if ! git -C "$PLUGIN_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then GIT_AVAILABLE=0 fi shipped_md_files() { find "$PLUGIN_ROOT" -type f -name '*.md' "$@" 2>/dev/null | sort | while IFS= read -r f; do if [ "$GIT_AVAILABLE" -eq 1 ] && git -C "$PLUGIN_ROOT" check-ignore -q -- "$f" 2>/dev/null; then continue fi printf '%s\n' "$f" done } # grep_shipped [find-predicates...] -> "path:line:text" lines grep_shipped() { local regex="$1" shift local f hits line shipped_md_files "$@" | while IFS= read -r f; do [ -n "$f" ] || continue hits="$(grep -nE "$regex" "$f" 2>/dev/null || true)" [ -n "$hits" ] || continue printf '%s\n' "$hits" | while IFS= read -r line; do printf '%s:%s\n' "$f" "$line" done done } if [ "$GIT_AVAILABLE" -eq 0 ]; then warn "git unavailable: checks (i) and (j) treat every Markdown file as shipped content" fi printf " shipped Markdown files in scope: %s (of %s on disk)\n" \ "$(shipped_md_files | wc -l | tr -d ' ')" \ "$(find "$PLUGIN_ROOT" -type f -name '*.md' 2>/dev/null | wc -l | tr -d ' ')" echo "" # ------------------------------------------------------- # Check (i): operator-private-context grep # ------------------------------------------------------- echo "--- (i) operator-private-context grep ---" I_HITS="$(grep_shipped '(kjell|vegvesen|NEXT-SESSION-PROMPT|REMEMBER\.md content from)' \ -not -path "$PLUGIN_ROOT/.claude/*" \ -not -path "$PLUGIN_ROOT/tests/*" \ || true)" if [ -z "$I_HITS" ]; then pass "no operator-private context leaks in shipped content" else while IFS= read -r hit; do fail "operator-private context leak in shipped content (brief NFR): $hit" done < <(printf '%s\n' "$I_HITS") fi echo "" # ------------------------------------------------------- # Check (j): Norwegian-leakage grep (WARN, not FAIL) # ------------------------------------------------------- echo "--- (j) Norwegian-leakage grep ---" J_HITS="$(grep_shipped '[æøåÆØÅ]' \ -not -path "$PLUGIN_ROOT/.claude/*" \ || true)" if [ -z "$J_HITS" ]; then pass "no Norwegian diacritics in shipped content" else while IFS= read -r hit; do warn "Norwegian diacritic in shipped content (review case-by-case): $hit" done < <(printf '%s\n' "$J_HITS") fi echo "" # ------------------------------------------------------- # Summary # ------------------------------------------------------- echo "=== Summary ===" printf "Pass: %d Fail: %d Warn: %d\n" "$PASS" "$FAIL" "$WARN" if [ "$FAIL" -gt 0 ]; then exit 1 fi exit 0