#!/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 "" # ------------------------------------------------------- # Check (i): operator-private-context grep # ------------------------------------------------------- echo "--- (i) operator-private-context grep ---" I_HITS="$(grep -rnE '(kjell|vegvesen|NEXT-SESSION-PROMPT|REMEMBER\.md content from)' \ "$PLUGIN_ROOT" \ --include='*.md' \ --exclude-dir='.claude' \ --exclude-dir='tests' \ --exclude='REMEMBER.md' \ --exclude='TODO.md' \ --exclude='NEXT-SESSION-PROMPT.local.md' \ 2>/dev/null || 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 -rnE '[æøåÆØÅ]' \ "$PLUGIN_ROOT" \ --include='*.md' \ --exclude-dir='.claude' \ --exclude='REMEMBER.md' \ --exclude='TODO.md' \ --exclude='NEXT-SESSION-PROMPT.local.md' \ 2>/dev/null || 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