ms-ai-architect/tests/validate-plugin.sh
Kjell Tore Guttormsen b68514487c fix(ms-ai-architect): RX-P2 reg-kjede-wiring + komprehensiv agent-sti-forankring [skip-docs]
- classify->dpia->ros kjede-wiring: dpia/ros Task-templater far eksplisitte
  AI Act-klassifiserings- + DPIA-funn-felt (kjede-data agentene allerede branchet pa)
- dpia-agent uutforbar "spor bruker" -> "marker vurderingen" (Hvis-ikke-klassifisert
  + Error Handling); speilet til ros-analysis-agent
- adr-writer-agent: Write-verktoy fjernet, returnerer ADR-markdown til hovedkontekst
  (command er eneste skriver -- subagenter skriver aldri)
- KB-sti-forankring (komprehensiv): 36 bare referanse-subdir-stier i 6 agenter +
  2 commands fullkvalifisert med CLAUDE_PLUGIN_ROOT/skills/<skill>/references/ ---
  ogsa innen-skill kortformer, uresolverbare i installert modus
- mekanisme: validate-plugin.sh Check 6d (bare-subdir-lint m/ hyphen-guard) +
  negativ-probe-test (beviser tenner) + RX-P2 wiring-regresjonstester

Suite 859/859 exit 0. validate-plugin 250 PASS / 0 FAIL. 145 forankrede stier
verifisert eksisterende (0 mangler).
2026-07-15 12:17:01 +02:00

395 lines
14 KiB
Bash
Executable file

#!/bin/bash
# validate-plugin.sh — Static validation for ms-ai-architect plugin
# Usage: bash tests/validate-plugin.sh
set -euo pipefail
# Colors for output
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() { echo -e "${GREEN}$1${NC}"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}$1${NC}"; FAIL=$((FAIL + 1)); }
warn() { echo -e "${YELLOW}$1${NC}"; WARN=$((WARN + 1)); }
echo "=== ms-ai-architect Plugin Validation ==="
echo "Plugin root: $PLUGIN_ROOT"
echo ""
# -------------------------------------------------------
# Check 1: Agent Frontmatter
# -------------------------------------------------------
echo "--- Check 1: Agent Frontmatter ---"
VALID_MODELS="opus sonnet haiku"
VALID_COLORS="blue green yellow purple cyan red orange magenta white"
for agent_file in "$PLUGIN_ROOT"/agents/*.md; do
[ -f "$agent_file" ] || continue
basename_file="$(basename "$agent_file")"
# Must have --- on line 1
first_line="$(head -n 1 "$agent_file")"
if [ "$first_line" != "---" ]; then
fail "$basename_file: missing frontmatter delimiter (---) on line 1"
continue
fi
# Extract frontmatter (between first and second ---)
frontmatter="$(sed -n '1,/^---$/{ /^---$/d; p; }' "$agent_file" | sed '1d')"
# sed '1d' removes the first --- captured; we actually need lines between first and second ---
# Redo: extract lines between line 2 and next ---
frontmatter="$(awk 'NR==1{next} /^---$/{exit} {print}' "$agent_file")"
# Check required fields
for field in "name:" "description:" "model:" "color:" "tools:"; do
if echo "$frontmatter" | grep -q "^${field}"; then
pass "$basename_file: has $field"
elif echo "$frontmatter" | grep -q "^ *${field}"; then
# indented (part of multiline) - still counts for description
pass "$basename_file: has $field"
else
# description can be multi-line with |
if [ "$field" = "description:" ] && echo "$frontmatter" | grep -q "description:"; then
pass "$basename_file: has $field"
else
fail "$basename_file: missing $field"
fi
fi
done
# Validate model value
model_value="$(echo "$frontmatter" | grep "^model:" | sed 's/^model: *//' | tr -d '[:space:]')"
if [ -n "$model_value" ]; then
model_valid=false
for m in $VALID_MODELS; do
if [ "$model_value" = "$m" ]; then
model_valid=true
break
fi
done
if $model_valid; then
pass "$basename_file: model '$model_value' is valid"
else
fail "$basename_file: model '$model_value' is not valid (expected: $VALID_MODELS)"
fi
fi
# Validate color value
color_value="$(echo "$frontmatter" | grep "^color:" | sed 's/^color: *//' | tr -d '[:space:]')"
if [ -n "$color_value" ]; then
color_valid=false
for c in $VALID_COLORS; do
if [ "$color_value" = "$c" ]; then
color_valid=true
break
fi
done
if $color_valid; then
pass "$basename_file: color '$color_value' is valid"
else
fail "$basename_file: color '$color_value' is not valid (expected: $VALID_COLORS)"
fi
fi
# Validate tools is a JSON array (starts with [)
tools_line="$(echo "$frontmatter" | grep "^tools:" || true)"
if [ -n "$tools_line" ]; then
tools_value="$(echo "$tools_line" | sed 's/^tools: *//')"
if echo "$tools_value" | grep -q '^\['; then
pass "$basename_file: tools is a JSON array"
else
fail "$basename_file: tools is not a JSON array (got: $tools_value)"
fi
fi
done
echo ""
# -------------------------------------------------------
# Check 2: Command Frontmatter
# -------------------------------------------------------
echo "--- Check 2: Command Frontmatter ---"
for cmd_file in "$PLUGIN_ROOT"/commands/*.md; do
[ -f "$cmd_file" ] || continue
basename_file="$(basename "$cmd_file")"
basename_noext="${basename_file%.md}"
# Must have --- on line 1
first_line="$(head -n 1 "$cmd_file")"
if [ "$first_line" != "---" ]; then
fail "$basename_file: missing frontmatter delimiter (---) on line 1"
continue
fi
# Extract frontmatter
frontmatter="$(awk 'NR==1{next} /^---$/{exit} {print}' "$cmd_file")"
# Check required fields: name, description
for field in "name:" "description:"; do
if echo "$frontmatter" | grep -q "${field}"; then
pass "$basename_file: has $field"
else
fail "$basename_file: missing $field"
fi
done
# Check allowed-tools (warn if missing)
if echo "$frontmatter" | grep -q "allowed-tools:"; then
pass "$basename_file: has allowed-tools"
else
warn "$basename_file: missing allowed-tools (recommended)"
fi
# Validate name matches filename pattern (allows both "command" and "plugin:command")
name_value="$(echo "$frontmatter" | grep "^name:" | sed 's/^name: *//' | tr -d '[:space:]')"
name_suffix="${name_value##*:}"
if [ -n "$name_value" ] && { [ "$name_value" = "$basename_noext" ] || [ "$name_suffix" = "$basename_noext" ]; }; then
pass "$basename_file: name matches filename"
elif [ -n "$name_value" ]; then
fail "$basename_file: name '$name_value' does not match filename '$basename_noext'"
fi
done
echo ""
# -------------------------------------------------------
# Check 3: Encoding Validation
# -------------------------------------------------------
echo "--- Check 3: Encoding Validation ---"
encoding_issues=0
for dir in agents commands skills; do
dir_path="$PLUGIN_ROOT/$dir"
[ -d "$dir_path" ] || continue
while IFS= read -r -d '' mdfile; do
basename_file="$(basename "$mdfile")"
rel_path="${mdfile#$PLUGIN_ROOT/}"
# Check for broken UTF-8 sequences
if grep -ql 'æ\|ø\|Ã¥\|Ã\†\|Ø\|Ã…' "$mdfile" 2>/dev/null; then
fail "$rel_path: broken æ/ø/å encoding detected"
encoding_issues=$((encoding_issues + 1))
fi
if grep -ql 'â€"' "$mdfile" 2>/dev/null; then
fail "$rel_path: broken em-dash/en-dash encoding detected"
encoding_issues=$((encoding_issues + 1))
fi
done < <(find "$dir_path" -name '*.md' -print0)
done
if [ "$encoding_issues" -eq 0 ]; then
pass "No encoding issues found in agents/, commands/, skills/"
fi
echo ""
# -------------------------------------------------------
# Check 4: KB Reference Validation
# -------------------------------------------------------
echo "--- Check 4: KB Reference Validation ---"
for agent_file in "$PLUGIN_ROOT"/agents/*.md; do
[ -f "$agent_file" ] || continue
basename_file="$(basename "$agent_file")"
# Extract lines referencing references/ paths
ref_paths="$(grep -o 'references/[a-zA-Z0-9_-]*/\?' "$agent_file" | sort -u || true)"
if [ -z "$ref_paths" ]; then
continue
fi
while IFS= read -r ref_path; do
# Normalize: remove trailing slash, build full path relative to skill references
ref_dir="$(echo "$ref_path" | sed 's|/$||')"
# Check across all skill directories
full_path=""
for skill_dir in "$PLUGIN_ROOT"/skills/*/; do
if [ -d "${skill_dir}${ref_dir}" ]; then
full_path="${skill_dir}${ref_dir}"
break
fi
done
if [ -z "$full_path" ]; then
full_path="$PLUGIN_ROOT/skills/ms-ai-engineering/$ref_dir"
fi
if [ -d "$full_path" ]; then
# Check if directory has files
file_count="$(find "$full_path" -maxdepth 1 -name '*.md' -type f | wc -l | tr -d ' ')"
if [ "$file_count" -gt 0 ]; then
pass "$basename_file: $ref_dir/ exists ($file_count files)"
else
warn "$basename_file: $ref_dir/ exists but is empty"
fi
else
fail "$basename_file: referenced $ref_dir/ does not exist at $full_path"
fi
done <<< "$ref_paths"
done
echo ""
# -------------------------------------------------------
# Check 5: Plugin.json Validation
# -------------------------------------------------------
echo "--- Check 5: Plugin.json Validation ---"
plugin_json="$PLUGIN_ROOT/.claude-plugin/plugin.json"
if [ ! -f "$plugin_json" ]; then
fail "plugin.json not found at .claude-plugin/plugin.json"
else
pass "plugin.json exists"
# Check required fields
for field in "name" "version" "description"; do
if grep -q "\"$field\"" "$plugin_json"; then
pass "plugin.json: has \"$field\""
else
fail "plugin.json: missing \"$field\""
fi
done
# Check auto_discover: optional field (not required in ktg-plugin-marketplace format)
if grep -q '"auto_discover"' "$plugin_json"; then
auto_val="$(grep '"auto_discover"' "$plugin_json" | grep -o 'true\|false')"
if [ "$auto_val" = "true" ]; then
pass "plugin.json: auto_discover is true"
else
fail "plugin.json: auto_discover is not true (got: $auto_val)"
fi
else
pass "plugin.json: auto_discover not present (auto-discovery via hooks.json)"
fi
fi
echo ""
# -------------------------------------------------------
# Check 6: Install-mode Path & Delegation Safety (RX-P1)
# -------------------------------------------------------
# Bundled-file references and agent delegations must resolve when the plugin is
# INSTALLED from a marketplace (cwd = user project, not plugin root). Relative
# 'skills/...' paths and bare/wrong-namespace agent names silently break there.
echo "--- Check 6: Install-mode Path & Delegation Safety ---"
# 6a: every bundled 'skills/' reference must be ${CLAUDE_PLUGIN_ROOT}-anchored
path_offenders=0
for f in "$PLUGIN_ROOT"/commands/*.md "$PLUGIN_ROOT"/agents/*.md; do
[ -f "$f" ] || continue
rel="${f#$PLUGIN_ROOT/}"
# Blank out correctly-anchored refs, then flag any remaining bare 'skills/'
bare="$(sed 's|${CLAUDE_PLUGIN_ROOT}/skills/|__ANCHORED__/|g' "$f" | grep -nE 'skills/[a-zA-Z0-9_-]+/' || true)"
if [ -n "$bare" ]; then
while IFS= read -r bline; do
[ -n "$bline" ] || continue
lineno="${bline%%:*}"
fail "$rel:$lineno: bare 'skills/' path (needs \${CLAUDE_PLUGIN_ROOT}/ prefix for installed mode)"
path_offenders=$((path_offenders + 1))
done <<< "$bare"
fi
done
if [ "$path_offenders" -eq 0 ]; then
pass "All skills/ references are \${CLAUDE_PLUGIN_ROOT}-anchored"
fi
# 6b: command agent-delegations must use 'general-purpose' or the registered
# scoped name '<plugin>:<agent>' (bare names / wrong namespace break installed)
PLUGIN_NAME="$(grep -o '"name"[^,]*' "$plugin_json" | head -1 | sed 's/.*: *"//; s/"//')"
deleg_offenders=0
while IFS= read -r hit; do
[ -n "$hit" ] || continue
loc="${hit%%:Task(*}" # /abs/commands/foo.md:NN
token="${hit##*:Task(}" # delegation token
rel="${loc%:*}"; rel="${rel#$PLUGIN_ROOT/}"
lineno="${loc##*:}"
if [ "$token" = "general-purpose" ]; then
continue
fi
case "$token" in
"$PLUGIN_NAME":*)
suffix="${token##*:}"
if [ -f "$PLUGIN_ROOT/agents/$suffix.md" ]; then
continue
else
fail "$rel:$lineno: Task($token) — no matching agents/$suffix.md"
deleg_offenders=$((deleg_offenders + 1))
fi
;;
*)
fail "$rel:$lineno: Task($token) — use 'general-purpose' or '$PLUGIN_NAME:<agent>' (bare/wrong-namespace breaks installed mode)"
deleg_offenders=$((deleg_offenders + 1))
;;
esac
done < <(grep -noE 'Task\([a-zA-Z0-9_:.-]+' "$PLUGIN_ROOT"/commands/*.md 2>/dev/null || true)
if [ "$deleg_offenders" -eq 0 ]; then
pass "All command agent-delegations use general-purpose or registered scoped names"
fi
# 6c: no sonnet/haiku pinned in Task delegations (operator directive: opus only)
model_hits="$(grep -nE 'Task\([^)]*(sonnet|haiku)' "$PLUGIN_ROOT"/commands/*.md 2>/dev/null || true)"
if [ -n "$model_hits" ]; then
while IFS= read -r mline; do
[ -n "$mline" ] || continue
fail "${mline#$PLUGIN_ROOT/}: non-opus model pinned in Task delegation"
done <<< "$model_hits"
else
pass "No sonnet/haiku pinned in Task delegations"
fi
# 6d: every bundled reference-subdir path must be ${CLAUDE_PLUGIN_ROOT}-anchored.
# Short-forms like `responsible-ai/foo.md` (a reference subdir + file, without the
# full skills/<skill>/references/ prefix) resolve only in dev cwd -- installed mode
# breaks. A path is anchored iff the subdir is immediately preceded by '/'
# (.../references/<subdir>/). The (^|[^A-Za-z0-9._/-]) guard fires on bare short-forms
# (preceded by backtick/space/comma/colon) while skipping anchored paths (preceded
# by '/') and compound subdir names (e.g. 'architecture' inside 'rag-architecture',
# preceded by '-').
REF_SUBDIRS='architecture|copilot-extensibility|development|platforms|prompt-engineering|agent-orchestration|api-management|azure-ai-services|data-engineering|mlops-genaiops|multi-modal|rag-architecture|monitoring-observability|norwegian-public-sector-governance|responsible-ai|bcdr|hybrid-edge|ai-security-engineering|cost-optimization|performance-scalability'
subdir_offenders=0
for f in "$PLUGIN_ROOT"/commands/*.md "$PLUGIN_ROOT"/agents/*.md; do
[ -f "$f" ] || continue
rel="${f#$PLUGIN_ROOT/}"
bare="$(grep -nE "(^|[^A-Za-z0-9._/-])($REF_SUBDIRS)/[A-Za-z0-9._-]+\.md" "$f" || true)"
if [ -n "$bare" ]; then
while IFS= read -r bline; do
[ -n "$bline" ] || continue
lineno="${bline%%:*}"
fail "$rel:$lineno: bare reference-subdir path (needs \${CLAUDE_PLUGIN_ROOT}/skills/<skill>/references/ prefix for installed mode)"
subdir_offenders=$((subdir_offenders + 1))
done <<< "$bare"
fi
done
if [ "$subdir_offenders" -eq 0 ]; then
pass "All reference-subdir paths are \${CLAUDE_PLUGIN_ROOT}-anchored"
fi
echo ""
# -------------------------------------------------------
# Summary
# -------------------------------------------------------
echo "=== Results ==="
echo -e "${GREEN}PASS: $PASS${NC}"
echo -e "${RED}FAIL: $FAIL${NC}"
echo -e "${YELLOW}WARN: $WARN${NC}"
if [ $FAIL -gt 0 ]; then
echo -e "${RED}VALIDATION FAILED${NC}"
exit 1
else
echo -e "${GREEN}VALIDATION PASSED${NC}"
exit 0
fi