#!/usr/bin/env bash set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT="$REPO_ROOT/scripts/check-manifests.sh" PASS=0 FAIL=0 pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } # One trap over a registry rather than a fresh `trap 'rm -rf "$FIXTUREn"' EXIT` # per fixture: each such trap REPLACES the previous one, so only the last # fixture was ever cleaned and the rest leaked into TMPDIR every run. Same # pattern as tests/test-check-vale-style-sync.sh and # tests/test-check-scope-walkup-sync.sh; the emptiness guard is there because # bash 3.2 treats "${arr[@]}" on an empty array as unbound under `set -u`. FIXTURES=() cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; } trap cleanup EXIT # Helper: make a minimal valid repo fixture with marketplace + plugin structure. # Per ADR-0015/ADR-0017, the manifest check-manifests.sh validates is # .claude-plugin/plugin.json (compiled output) -- not the root-level plugin.json, # which was deleted repo-wide, and not the skills/hooks/mcpServers/agents pointer # fields apm's compiler deliberately never populates (see scripts/check-manifests.sh's # own header comment). Content-presence drift is scripts/sync-plugin-content.sh's job. make_valid_fixture() { local dir dir="$(mktemp -d)" mkdir -p "$dir/.claude-plugin" mkdir -p "$dir/plugins/myplugin/.claude-plugin" cat > "$dir/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "myplugin", "source": "./plugins/myplugin" } ] } JSON cat > "$dir/plugins/myplugin/.claude-plugin/plugin.json" <<'JSON' { "name": "myplugin" } JSON echo "$dir" } # --- 1. Exits 0 against valid repo structure --- echo "" echo "--- exits 0 when all references are valid ---" FIXTURE="$(make_valid_fixture)" FIXTURES+=("$FIXTURE") if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then pass "exits 0 when all manifest references resolve" else fail "exited non-zero against a valid fixture" fi # --- 2. Exits 1 when plugin source dir is missing --- echo "" echo "--- exits 1 when plugin source directory missing ---" FIXTURE2="$(mktemp -d)" FIXTURES+=("$FIXTURE2") mkdir -p "$FIXTURE2/.claude-plugin" cat > "$FIXTURE2/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "ghost", "source": "./plugins/ghost" } ] } JSON if bash "$SCRIPT" "$FIXTURE2" > /dev/null 2>&1; then fail "exited 0 when plugin source dir is missing — expected exit 1" else pass "exits non-zero when plugin source directory does not exist" fi # --- 3. Exits 1 when .claude-plugin/plugin.json is missing from plugin dir --- echo "" echo "--- exits 1 when .claude-plugin/plugin.json missing from plugin directory ---" FIXTURE3="$(mktemp -d)" FIXTURES+=("$FIXTURE3") mkdir -p "$FIXTURE3/.claude-plugin" mkdir -p "$FIXTURE3/plugins/nomanifest" cat > "$FIXTURE3/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "nomanifest", "source": "./plugins/nomanifest" } ] } JSON if bash "$SCRIPT" "$FIXTURE3" > /dev/null 2>&1; then fail "exited 0 when .claude-plugin/plugin.json is missing — expected exit 1" else pass "exits non-zero when .claude-plugin/plugin.json is missing from plugin directory" fi # --- 4. Exits 1 when a remote-source plugin entry's local plugin still lacks a manifest --- # Remote sources (object-typed `source:`) are skipped entirely; only string (local path) # sources are checked. This guards that a mixed marketplace.json still catches a broken # local entry alongside a legitimately-skipped remote one. echo "" echo "--- exits 1 for a broken local entry even when a remote entry is present ---" FIXTURE4="$(mktemp -d)" FIXTURES+=("$FIXTURE4") mkdir -p "$FIXTURE4/.claude-plugin" mkdir -p "$FIXTURE4/plugins/broken" cat > "$FIXTURE4/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "remote-thing", "source": { "repo": "someorg/somerepo", "source": "github" } }, { "name": "broken", "source": "./plugins/broken" } ] } JSON if bash "$SCRIPT" "$FIXTURE4" > /dev/null 2>&1; then fail "exited 0 with a broken local entry present — expected exit 1" else pass "exits non-zero for a broken local entry even alongside a skipped remote entry" fi # --- 5. Non-.apm/ plugin with a broken pointer field is caught by the fallback path --- # apm-native plugins (.apm/ present) get their skills/hooks/mcpServers/agents # pointer-field validation from sync-plugin-content.sh --check instead (see this # script's header comment) -- but that script skips any plugin dir lacking .apm/ # outright, so a non-apm plugin's hand-authored plugin.json needs this script's own # fallback validation to catch a broken pointer field. echo "" echo "--- catches a broken pointer field in a non-apm plugin's plugin.json ---" FIXTURE5="$(mktemp -d)" FIXTURES+=("$FIXTURE5") mkdir -p "$FIXTURE5/.claude-plugin" mkdir -p "$FIXTURE5/plugins/legacy/.claude-plugin" cat > "$FIXTURE5/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "legacy", "source": "./plugins/legacy" } ] } JSON cat > "$FIXTURE5/plugins/legacy/.claude-plugin/plugin.json" <<'JSON' { "name": "legacy", "skills": ["./skills/does-not-exist"] } JSON if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then fail "exited 0 for a non-apm plugin with a broken skills pointer -- expected exit 1" else pass "catches a broken skills pointer field in a non-apm (no .apm/) plugin.json" fi # --- 6. Non-.apm/ plugin with valid pointer fields still passes (no false positive) --- echo "" echo "--- a non-apm plugin with valid pointer fields still passes ---" FIXTURE6="$(mktemp -d)" FIXTURES+=("$FIXTURE6") mkdir -p "$FIXTURE6/.claude-plugin" mkdir -p "$FIXTURE6/plugins/legacy-ok/.claude-plugin" mkdir -p "$FIXTURE6/plugins/legacy-ok/skills/real-skill" cat > "$FIXTURE6/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "legacy-ok", "source": "./plugins/legacy-ok" } ] } JSON cat > "$FIXTURE6/plugins/legacy-ok/.claude-plugin/plugin.json" <<'JSON' { "name": "legacy-ok", "skills": ["./skills/real-skill"] } JSON if bash "$SCRIPT" "$FIXTURE6" > /dev/null 2>&1; then pass "a non-apm plugin with a resolving skills pointer passes" else fail "exited non-zero for a non-apm plugin whose pointer fields all resolve" fi # --- 7. An .apm/ plugin with a broken pointer field is NOT caught here (delegated) --- # Guards against the fallback path in finding #6 accidentally widening to also # validate apm-native plugins, which would duplicate (and could disagree with) # sync-plugin-content.sh --check's own drift detection. echo "" echo "--- an apm-native plugin's pointer fields are left to sync-plugin-content.sh --check ---" FIXTURE7="$(mktemp -d)" FIXTURES+=("$FIXTURE7") mkdir -p "$FIXTURE7/.claude-plugin" mkdir -p "$FIXTURE7/plugins/apm-plugin/.claude-plugin" mkdir -p "$FIXTURE7/plugins/apm-plugin/.apm" cat > "$FIXTURE7/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "apm-plugin", "source": "./plugins/apm-plugin" } ] } JSON cat > "$FIXTURE7/plugins/apm-plugin/.claude-plugin/plugin.json" <<'JSON' { "name": "apm-plugin", "skills": ["./skills/does-not-exist"] } JSON if bash "$SCRIPT" "$FIXTURE7" > /dev/null 2>&1; then pass "an apm-native plugin (has .apm/) is not checked here, even with a broken pointer field" else fail "check-manifests.sh failed on an apm-native plugin -- pointer-field validation should be delegated, not duplicated" fi # --- 8. Disk -> marketplace: an apm package dir with no marketplace entry is caught --- # Both this script and sync-plugin-content.sh --all derive their plugin set from # marketplace.json, so before this check an unlisted plugins// was skipped by # every marketplace-derived gate at once while still being globbed by the # validate-plugins pre-commit hook -- two different notions of "the plugin set". # Per ADR-0015 marketplace.json is compiled from root apm.yml's marketplace.packages[], # so an on-disk apm package missing from it is compiled-output drift. echo "" echo "--- exits 1 for a plugins// apm package with no marketplace entry ---" FIXTURE8="$(mktemp -d)" FIXTURES+=("$FIXTURE8") mkdir -p "$FIXTURE8/.claude-plugin" mkdir -p "$FIXTURE8/plugins/listed/.claude-plugin" mkdir -p "$FIXTURE8/plugins/orphan/.claude-plugin" "$FIXTURE8/plugins/orphan/.apm/skills" cat > "$FIXTURE8/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "listed", "source": "./plugins/listed" } ] } JSON echo '{ "name": "listed" }' > "$FIXTURE8/plugins/listed/.claude-plugin/plugin.json" echo '{ "name": "orphan" }' > "$FIXTURE8/plugins/orphan/.claude-plugin/plugin.json" printf 'name: orphan\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE8/plugins/orphan/apm.yml" if bash "$SCRIPT" "$FIXTURE8" > /dev/null 2>&1; then fail "exited 0 for an on-disk apm package absent from marketplace.json -- expected exit 1" else pass "catches a plugins// apm package that produced no marketplace entry" fi # --- 9. A plugins// dir with none of the three plugin markers is not flagged --- # The trigger is apm.yml || .apm/ || .claude-plugin/plugin.json -- broad enough to match # the plugins/*/ set the validate-plugins hook globs, which is the disagreement this check # closes. A directory carrying none of the three is scratch and stays out of scope. echo "" echo "--- a plugins// directory with none of the three plugin markers is not flagged ---" FIXTURE9="$(mktemp -d)" FIXTURES+=("$FIXTURE9") mkdir -p "$FIXTURE9/.claude-plugin" mkdir -p "$FIXTURE9/plugins/listed/.claude-plugin" mkdir -p "$FIXTURE9/plugins/scratch/notes" cat > "$FIXTURE9/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "listed", "source": "./plugins/listed" } ] } JSON echo '{ "name": "listed" }' > "$FIXTURE9/plugins/listed/.claude-plugin/plugin.json" if bash "$SCRIPT" "$FIXTURE9" > /dev/null 2>&1; then pass "a plugins// directory with no plugin markers is left alone" else fail "flagged a non-package directory under plugins/ -- expected exit 0" fi # --- 9b. Each of the three markers on its own is enough to trigger the check --- # Keying only off apm.yml would leave a plugin dir carrying just .apm/ or just a # compiled .claude-plugin/plugin.json invisible -- exactly the class of gap this # check exists to close, since validate-plugins would still glob it. marker_case() { local label="$1" marker_setup="$2" dir dir="$(mktemp -d)" FIXTURES+=("$dir") mkdir -p "$dir/.claude-plugin" "$dir/plugins/listed/.claude-plugin" "$dir/plugins/orphan" cat > "$dir/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "listed", "source": "./plugins/listed" } ] } JSON echo '{ "name": "listed" }' > "$dir/plugins/listed/.claude-plugin/plugin.json" case "$marker_setup" in apm-dir) mkdir -p "$dir/plugins/orphan/.apm/skills" ;; plugin-json) mkdir -p "$dir/plugins/orphan/.claude-plugin" echo '{ "name": "orphan" }' > "$dir/plugins/orphan/.claude-plugin/plugin.json" ;; esac if bash "$SCRIPT" "$dir" > /dev/null 2>&1; then fail "an unlisted plugin dir carrying only $label was not flagged" else pass "an unlisted plugin dir carrying only $label is flagged" fi } echo "" echo "--- .apm/ alone and .claude-plugin/plugin.json alone each trigger the check ---" marker_case ".apm/" apm-dir marker_case ".claude-plugin/plugin.json" plugin-json # --- 9c. A vendored plugin declared with a remote-object source: is already listed --- # list_marketplace_local_plugins deliberately skips remote-object entries, so a # path-only listed/unlisted match reported a missing entry for a directory whose # entry is in fact right there -- telling the author to add what already exists. # The name axis of the match closes that. echo "" echo "--- a vendored plugin whose marketplace entry uses a remote source: is not flagged ---" FIXTURE9C="$(mktemp -d)" FIXTURES+=("$FIXTURE9C") mkdir -p "$FIXTURE9C/.claude-plugin" "$FIXTURE9C/plugins/vendored/.claude-plugin" cat > "$FIXTURE9C/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "vendored", "source": { "repo": "someorg/somerepo", "source": "github" } } ] } JSON echo '{ "name": "vendored" }' > "$FIXTURE9C/plugins/vendored/.claude-plugin/plugin.json" printf 'name: vendored\nversion: 1.2.3\ntype: skill\n' > "$FIXTURE9C/plugins/vendored/apm.yml" if bash "$SCRIPT" "$FIXTURE9C" > /dev/null 2>&1; then pass "a vendored dir matching a remote-source entry's name counts as listed" else fail "flagged a vendored plugin that already has a remote-source marketplace entry" fi # --- 9d. The name axis must NOT rescue an orphan via a LOCAL entry's name --- # A local entry's name need not equal the basename of the directory it points at. An # entry named "beta" pointing at ./plugins/alpha must not mark an unrelated, entirely # unlisted plugins/beta/ as listed -- local entries match on their exact path, so # extending the name fallback to them just reopens the gap this check exists to close. echo "" echo "--- a local entry's name does not rescue a same-named but unlisted directory ---" FIXTURE9D="$(mktemp -d)" FIXTURES+=("$FIXTURE9D") mkdir -p "$FIXTURE9D/.claude-plugin" "$FIXTURE9D/plugins/alpha/.claude-plugin" "$FIXTURE9D/plugins/beta" cat > "$FIXTURE9D/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "beta", "source": "./plugins/alpha" } ] } JSON echo '{ "name": "alpha" }' > "$FIXTURE9D/plugins/alpha/.claude-plugin/plugin.json" printf 'name: beta\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE9D/plugins/beta/apm.yml" if bash "$SCRIPT" "$FIXTURE9D" > /dev/null 2>&1; then fail "an unlisted plugins/beta/ was rescued by an unrelated local entry named beta -- expected exit 1" else pass "an unlisted directory is not rescued by a local entry that merely shares its name" fi # --- 10. Marketplace `source:` spelling variants still count as "listed" --- # The disk -> marketplace comparison canonicalizes both sides, so `plugins/x` and # `./plugins/x/` must resolve to the same directory as the glob's `plugins/x/`. # # The entry names deliberately DIFFER from the directory basenames. With names equal to # basenames this fixture proved nothing whenever the name axis was permissive: deleting # the canonicalization entirely still left it passing, because the name match rescued it. # Restricting the name axis to remote entries fixed that, but making the names differ is # what keeps this assertion honest independently of that restriction. echo "" echo "--- a marketplace source without ./ or with a trailing slash still counts as listed ---" FIXTURE10="$(mktemp -d)" FIXTURES+=("$FIXTURE10") mkdir -p "$FIXTURE10/.claude-plugin" mkdir -p "$FIXTURE10/plugins/bare/.claude-plugin" "$FIXTURE10/plugins/trailing/.claude-plugin" cat > "$FIXTURE10/.claude-plugin/marketplace.json" <<'JSON' { "name": "test-marketplace", "plugins": [ { "name": "bare-entry", "source": "plugins/bare" }, { "name": "trailing-entry", "source": "./plugins/trailing/" } ] } JSON echo '{ "name": "bare" }' > "$FIXTURE10/plugins/bare/.claude-plugin/plugin.json" echo '{ "name": "trailing" }' > "$FIXTURE10/plugins/trailing/.claude-plugin/plugin.json" printf 'name: bare\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE10/plugins/bare/apm.yml" printf 'name: trailing\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE10/plugins/trailing/apm.yml" if bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1; then pass "source: spelling variants are canonicalized before the listed/unlisted comparison" else fail "flagged a listed plugin because its source: string was spelled differently" fi echo "" echo "Results: $PASS passed, $FAIL failed" [[ $FAIL -eq 0 ]]