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
312 lines
10 KiB
Bash
Executable file
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
|