claude-design/tests/validate-plugin.sh
Kjell Tore Guttormsen 3696b8e283 fix(claude-design): derive shipped-content scope from git, not a name list
Checks (i) and (j) decided what counts as "shipped content" from a hardcoded
list of local-only file names (REMEMBER.md, TODO.md, NEXT-SESSION-PROMPT.local.md).
That list was written before the STATE.md convention replaced those three, so
STATE.md fell through it and was scanned as if it shipped. It does not: it is
gitignored and has zero tracked entries.

The resulting false positive was self-reproducing. Any STATE.md note explaining
why the check was red had to name the banned token, which made the check red.
Removing the offending line closed nothing; the next session that documented
the finding recreated it.

Two fixes were considered:

  (a) add STATE.md to the exclude list. One name, but the list stays a name
      list -- it rots again the next time a local-only file is renamed, which
      is precisely how this defect arrived.

  (b) derive the scope from git. No gitignored file can reopen the hole,
      whatever it is called.

(b) is implemented, via `git check-ignore` rather than `git ls-files`. Both
answer "is this shipped", but ls-files also drops untracked Markdown that is
NOT ignored -- new content on its way into the plugin, which is exactly when a
leak check should be looking. check-ignore keeps that in scope and excludes
only what git ignores. When git cannot answer (no repo, no binary) every file
is treated as shipped, so the checks fail loudly instead of passing on an
empty file list.

Verified both directions, denominators reported:
  known-positive: a real shipped reference/*.md carrying the banned token
                  -> FAIL, exit 1 (proves the check can still fire)
  known-negative: STATE.md, gitignored, carrying the same token
                  -> not flagged
  check (j) known-positive: shipped .md with Norwegian diacritics -> WARN
  scope: 22 shipped Markdown files of 31 on disk (9 gitignored: STATE.md +
         8 under .claude/)

validate-plugin.sh: Pass 16 / Fail 0 / Warn 0, exit 0 (was 14 / 1 / 23, exit 1)
verify.sh roll-up:  Pass 41 / Fail 0 / Warn 1, exit 0
Both re-run under /bin/bash 3.2.57 as well as bash 5.3.

The 23 warnings that disappeared were all STATE.md diacritics; they were never
shipped content, and check (j) carried the same name-list defect that (i) did.
Closes ORDRE 64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToLVakwASPe3pXsdEiothC
2026-08-17 23:22:38 +02:00

312 lines
10 KiB
Bash
Executable file

#!/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 <ERE> [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