Files
holocron/tests/test-sync-plugin-content.sh
Defame1297 cf625229f7 fix(scripts): detect the .apm/ content the mirror loses silently
apm's bundle exporter drops symlinks entirely, so a symlink under .apm/ never
reaches the mirror -- and no gate could see it, because every existing check
diffs the live mirror against a bundle-derived copy and both sides lack the
file. It is an absence with nothing left to mismatch against, the only class of
.apm/ content that vanishes without a trace. check_apm_symlinks reads the .apm/
source tree, where the loss is visible, and fails both modes. Reported rather
than resolved: dereferencing would make a real sync emit content the bundle does
not contain, which is the reimplementation ADR-0017 rejects.

--check --all could also pass having verified fewer plugins than the marketplace
lists: a plugin whose .apm/ had gone was SKIPped rather than counted, and the
earlier floor only caught zero. The count is now checked against the marketplace's
own local-package list. There is no exempt state -- ADR-0015 makes .apm/ the sole
authoring source for every local plugin, so a listed plugin without one is drift.

On the Copilot hooks gap, the decision is to document, not implement. Copilot
declares no hooks path and apm emits none, which looks like the mcpServers case
-- but that exception holds because .mcp.json is one host-agnostic format both
ecosystems read, so a pointer to it is true whatever it contains. Hooks have no
shared format: Claude expects nested matcher groups under PascalCase events,
Copilot requires version: 1, camelCase, and a bash/powershell split. apm merges
.apm/hooks/*.json into exactly one file, at Claude's convention path. A pointer
would assert a Claude-shaped file is Copilot-shaped -- an incomplete manifest
traded for a wrong one -- and it is not inert today either, since {"hooks": {}}
lacks Copilot's mandatory version key. A test pins the decision, so restoring the
pointer fails until someone confronts the schema mismatch.

Tests: 77 -> 92 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 14:20:47 +00:00

1292 lines
59 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/sync-plugin-content.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
if ! command -v apm &>/dev/null; then
echo "apm not installed -- skipping (see kyberforge:apm-install)" >&2
exit 77
fi
# Minimal fixture exercising every mirrored category -- all five of
# scripts/sync-plugin-content.sh's MIRROR_DIRS (agents, skills, commands,
# instructions, extensions) plus the merged hooks file -- without needing network
# access (no apm.yml dependencies). It also carries a .apm/prompts/ entry, which has
# no MIRROR_DIRS destination of its own: apm folds prompts into commands/. Two
# deliberately-shaped skill subdirectories:
#
# skills/hello/tests/ -- a dev-time fixture that must NOT be mirrored
# skills/hello/assets/templates/tests/ -- a template asset that MUST be mirrored
#
# Those two are the same basename at different depths. The exclusion is depth-scoped
# for exactly this reason: the real skill-author skill ships a template tree it
# scaffolds from, and a depth-agnostic strip amputated it.
#
# skills/hello/scripts/run.sh is executable so the mode/symlink drift checks have a
# real executable to tamper with.
make_fixture() {
local dir
dir="$(mktemp -d)"
mkdir -p "$dir/.apm/skills/hello/tests" "$dir/.apm/skills/hello/scripts" \
"$dir/.apm/skills/hello/assets/templates/tests" "$dir/.apm/agents" \
"$dir/.apm/hooks" "$dir/.apm/commands" "$dir/.apm/instructions" \
"$dir/.apm/extensions" "$dir/.apm/prompts"
cat > "$dir/apm.yml" <<'YAML'
name: fixture
version: 0.0.1
description: fixture
license: MIT
type: hybrid
targets:
- claude
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
cat > "$dir/.apm/skills/hello/SKILL.md" <<'EOF'
---
name: hello
description: hello
---
Hello.
EOF
cat > "$dir/.apm/skills/hello/tests/sample.bats" <<'EOF'
@test "dummy" { true; }
EOF
cat > "$dir/.apm/skills/hello/assets/templates/tests/README.md" <<'EOF'
Template asset: scaffolded into a new skill, not a dev fixture of this one.
EOF
cat > "$dir/.apm/skills/hello/scripts/run.sh" <<'EOF'
#!/usr/bin/env bash
echo hi
EOF
chmod +x "$dir/.apm/skills/hello/scripts/run.sh"
cat > "$dir/.apm/agents/foo.agent.md" <<'EOF'
---
name: foo
description: foo
---
Foo.
EOF
cat > "$dir/.apm/commands/mycmd.md" <<'EOF'
---
description: mycmd
---
Do a thing.
EOF
cat > "$dir/.apm/prompts/greet.prompt.md" <<'EOF'
---
description: greet
---
Greet the user.
EOF
cat > "$dir/.apm/instructions/style.instructions.md" <<'EOF'
---
applyTo: "**"
---
Be consistent.
EOF
cat > "$dir/.apm/extensions/thing.md" <<'EOF'
Extension content.
EOF
cat > "$dir/.apm/hooks/hooks.json" <<'EOF'
{"hooks": {"PreToolUse": []}}
EOF
echo "$dir"
}
# Same base fixture, but with a copilot target (so apm pack produces
# .github/plugin/plugin.json) and a caller-supplied .mcp.json -- for exercising
# reinject_mcp_servers().
make_fixture_with_mcp() {
local mcp_json="$1" dir
dir="$(mktemp -d)"
mkdir -p "$dir/.apm/skills/hello" "$dir/.apm/agents"
cat > "$dir/apm.yml" <<'YAML'
name: fixture
version: 0.0.1
description: fixture
license: MIT
type: hybrid
targets:
- claude
- copilot
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
cat > "$dir/.apm/skills/hello/SKILL.md" <<'EOF'
---
name: hello
description: hello
---
Hello.
EOF
cat > "$dir/.apm/agents/foo.agent.md" <<'EOF'
---
name: foo
description: foo
---
Foo.
EOF
printf '%s' "$mcp_json" > "$dir/.mcp.json"
echo "$dir"
}
CLEANUP_DIRS=()
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
track() { CLEANUP_DIRS+=("$1"); }
# --- 1. --check reports drift before any sync has run ---
echo ""
echo "--- --check reports drift on an unsynced fixture ---"
FIXTURE="$(make_fixture)"; track "$FIXTURE"
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
fail "exited 0 on an unsynced fixture — expected drift (exit 1)"
else
pass "exits non-zero (drift) before syncing"
fi
# --- 2. A real sync creates the flat mirror and exits 0 ---
echo ""
echo "--- real sync creates every MIRROR_DIRS category plus hooks/hooks.json ---"
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1 \
&& [[ -f "$FIXTURE/skills/hello/SKILL.md" ]] \
&& [[ -f "$FIXTURE/agents/foo.agent.md" ]] \
&& [[ -f "$FIXTURE/commands/mycmd.md" ]] \
&& [[ -f "$FIXTURE/instructions/style.instructions.md" ]] \
&& [[ -f "$FIXTURE/extensions/thing.md" ]] \
&& [[ -f "$FIXTURE/hooks/hooks.json" ]]; then
pass "sync creates the expected flat mirror for all five MIRROR_DIRS plus hooks/hooks.json"
else
fail "sync did not create the expected flat mirror"
fi
# --- 2b. The merged hooks file goes to hooks/hooks.json, never the plugin root ---
# Claude Code convention-scans `hooks/hooks.json` at the plugin root (see
# plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's
# "Plugin Directory Layout" table, quoted in ADR-0017), and the compiled plugin.json
# carries no `hooks` pointer that could redirect it. A root-level hooks.json is read
# by nothing.
echo ""
echo "--- the merged hooks file is not left at the plugin root ---"
if [[ ! -e "$FIXTURE/hooks.json" ]]; then
pass "no root-level hooks.json after a sync"
else
fail "sync wrote hooks.json to the plugin root — Claude Code scans hooks/hooks.json"
fi
# --- 2c. hooks/hooks.json is newline-terminated ---
# normalize_trailing_newline() exists so pre-commit's end-of-file-fixer does not
# re-dirty the tree on every sync: apm's bundle exporter emits hooks.json with no
# trailing newline, the committed file has one.
echo ""
echo "--- the synced hooks file ends in a newline ---"
if [[ -n "$(tail -c 1 "$FIXTURE/hooks/hooks.json")" ]]; then
fail "hooks/hooks.json has no trailing newline — end-of-file-fixer will re-dirty it every sync"
else
pass "hooks/hooks.json is newline-terminated"
fi
# --- 3. tests/ fixtures are excluded from the mirror ---
echo ""
echo "--- tests/ subdirectories are not mirrored ---"
if [[ ! -e "$FIXTURE/skills/hello/tests" ]]; then
pass "skills/hello/tests/ was not copied into the mirror"
else
fail "skills/hello/tests/ was copied into the mirror — should be excluded"
fi
# --- 3b. ...but a deeper tests/ that is a template ASSET must survive ---
# The exclusion above is depth-scoped to <category>/<name>/tests. Stripping every
# directory named tests at any depth also deletes template trees a skill ships for
# its own scaffolder to copy from — which is what broke the mirrored
# skills/skill-author/scripts/new-skill.sh (`sed: can't read .../tests/README.md`,
# half-written scaffold left behind) while the .apm/ original still worked.
echo ""
echo "--- a tests/ directory nested under assets/templates/ is preserved ---"
if [[ -f "$FIXTURE/skills/hello/assets/templates/tests/README.md" ]]; then
pass "skills/hello/assets/templates/tests/ survived the sync"
else
fail "skills/hello/assets/templates/tests/ was stripped — template assets are not dev fixtures"
fi
# --- 4. --check is clean immediately after a real sync ---
echo ""
echo "--- --check is clean right after syncing ---"
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
pass "no drift reported immediately after syncing"
else
fail "drift reported right after syncing — sync and check disagree"
fi
# --- 5. New .apm/ content is detected as drift, and a re-sync clears it ---
echo ""
echo "--- new .apm/ content is detected as drift and cleared by re-sync ---"
mkdir -p "$FIXTURE/.apm/skills/second"
cat > "$FIXTURE/.apm/skills/second/SKILL.md" <<'EOF'
---
name: second
description: second
---
Second.
EOF
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
fail "no drift reported after adding a new skill under .apm/ — expected drift"
else
pass "new .apm/ content is detected as drift"
bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
pass "re-sync clears the drift"
else
fail "re-sync did not clear the drift"
fi
fi
# --- 6. A plugin dir with no .apm/ is skipped cleanly, not treated as an error ---
echo ""
echo "--- a plugin dir with no .apm/ is skipped, not failed ---"
NO_APM="$(mktemp -d)"; track "$NO_APM"
if bash "$SCRIPT" "$NO_APM" > /dev/null 2>&1 && bash "$SCRIPT" --check "$NO_APM" > /dev/null 2>&1; then
pass "a plugin dir with no .apm/ exits 0 in both real and --check mode"
else
fail "a plugin dir with no .apm/ should exit 0 (skip), not fail"
fi
# --- 7. A plugin dir that doesn't exist at all is a hard failure, not a skip ---
echo ""
echo "--- a plugin dir that does not exist fails, distinct from an existing-but-empty one ---"
MISSING_ROOT="$(mktemp -d)"; track "$MISSING_ROOT"
MISSING="$MISSING_ROOT/does-not-exist"
if bash "$SCRIPT" "$MISSING" > /dev/null 2>&1; then
fail "exited 0 for a plugin dir that does not exist — expected a hard failure"
else
pass "a nonexistent plugin dir fails instead of silently skipping"
fi
# --- 8. --check never mutates the real plugin root, even on first-time manifest creation ---
echo ""
echo "--- --check does not create .claude-plugin/plugin.json or .github/plugin/plugin.json ---"
FIXTURE8="$(make_fixture)"; track "$FIXTURE8"
bash "$SCRIPT" --check "$FIXTURE8" > /dev/null 2>&1 || true
if [[ ! -e "$FIXTURE8/.claude-plugin/plugin.json" ]] && [[ ! -e "$FIXTURE8/.github/plugin/plugin.json" ]]; then
pass "--check leaves the real plugin root without a first-write plugin.json"
else
fail "--check created plugin.json in the real plugin root — it must never mutate it"
fi
# --- 9. A duplicate plugin-dir basename among arguments fails fast, not silently ---
echo ""
echo "--- duplicate plugin dir basenames among arguments are rejected ---"
DUP_PARENT_A="$(mktemp -d)"; track "$DUP_PARENT_A"
DUP_PARENT_B="$(mktemp -d)"; track "$DUP_PARENT_B"
mkdir -p "$DUP_PARENT_A/dup" "$DUP_PARENT_B/dup"
if bash "$SCRIPT" "$DUP_PARENT_A/dup" "$DUP_PARENT_B/dup" > /dev/null 2>&1; then
fail "exited 0 with two plugin-dir arguments sharing a basename — expected a collision error"
else
pass "rejects two plugin-dir arguments that share a basename"
fi
# --- 10. Real sync re-injects mcpServers as a PATH that apm's Copilot builder strips ---
# The payload is the string ".mcp.json", not the resolved server objects: Copilot's
# schema types the field "string or object", and only the string form is incapable
# of carrying a credential into a committed, published manifest (see case 23).
echo ""
echo "--- real sync re-injects mcpServers into .github/plugin/plugin.json as a path ---"
FIXTURE10="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE10"
bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1
if jq -e '.mcpServers == ".mcp.json"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
pass "mcpServers is the string \".mcp.json\" in .github/plugin/plugin.json after a real sync"
else
fail "mcpServers was not re-injected as the path \".mcp.json\" (got: $(jq -c '.mcpServers // "<absent>"' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null))"
fi
# The inlined-object form is what leaked; assert it is gone, not merely that a
# key exists. `jq -e '.mcpServers == ".mcp.json"'` above already implies this, but
# a future refactor that emits an object again should fail on the reason, not just
# on the shape.
if jq -e '.mcpServers | type == "object"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
fail "mcpServers was inlined as an object — that is the form that copies .mcp.json verbatim into a published manifest"
else
pass "mcpServers is not an inlined object"
fi
# --- 10b. The re-injection preserves the manifest's own mode, rather than importing one ---
# reinject_mcp_servers used to build its replacement in a `mktemp` file (mode 0600)
# and `mv` it over the manifest, carrying 0600 onto a tracked, published file. Git
# records only the exec bit, so the demotion survived every commit unnoticed — this
# repo's own plugins/bin/.github/plugin/plugin.json really was 0600 on disk while
# its five siblings were 0644.
#
# The fix is `cat "$tmp" >"$dst"`, which keeps the destination inode: the manifest
# ends up at whatever mode apm pack gave it a moment earlier, i.e. 0666 & ~umask like
# any other freshly created file. So this is asserted across two umasks rather than
# against a hardcoded 644 — that is what distinguishes "preserved" from "assigned".
# A `mv` of the mktemp yields 600 under both; a `chmod 644` yields 644 under both,
# which is the umask dependence that made --check fail on a umask-002 checkout.
mode_of() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
}
echo ""
echo "--- the mcpServers re-injection leaves the manifest at the umask's own file mode ---"
for UMASK10B in 022 002; do
case "$UMASK10B" in
022) EXPECT10B=644 ;;
002) EXPECT10B=664 ;;
esac
FIXTURE10B="$(umask "$UMASK10B"; make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"
track "$FIXTURE10B"
(umask "$UMASK10B"; bash "$SCRIPT" "$FIXTURE10B" > /dev/null 2>&1)
MODE10B="$(mode_of "$FIXTURE10B/.github/plugin/plugin.json")"
if [[ "$MODE10B" == "$EXPECT10B" ]]; then
pass "under umask $UMASK10B the re-injected manifest is $EXPECT10B (its own mode, not mktemp's 600 and not a hardcoded one)"
else
fail "under umask $UMASK10B the re-injected manifest is $MODE10B, expected $EXPECT10B"
fi
done
# --- 11. An empty .mcp.json does not add a redundant mcpServers: {} ---
echo ""
echo "--- an empty .mcp.json does not add mcpServers: {} ---"
FIXTURE11="$(make_fixture_with_mcp '{"mcpServers":{}}')"; track "$FIXTURE11"
bash "$SCRIPT" "$FIXTURE11" > /dev/null 2>&1
if jq -e 'has("mcpServers") | not' "$FIXTURE11/.github/plugin/plugin.json" > /dev/null 2>&1; then
pass "an empty .mcp.json does not add mcpServers to .github/plugin/plugin.json"
else
fail "an empty .mcp.json still added mcpServers -- should match apm's own omit-when-empty convention"
fi
# --- 12. --check detects drift in the compiled plugin.json (apm.yml content changed) ---
echo ""
echo "--- --check detects plugin.json content drift from apm.yml after a version bump ---"
FIXTURE12="$(make_fixture)"; track "$FIXTURE12"
bash "$SCRIPT" "$FIXTURE12" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
pass "check is clean right after the initial sync (baseline for this test)"
else
fail "check reported drift right after the initial sync -- can't test the version-bump case"
fi
# Bump the version in apm.yml without re-syncing -- the compiled
# .claude-plugin/plugin.json is now stale relative to what apm pack would
# currently produce.
cat > "$FIXTURE12/apm.yml" <<'YAML'
name: fixture
version: 0.0.2
description: fixture
license: MIT
type: hybrid
targets:
- claude
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
fail "no drift reported after bumping apm.yml's version -- plugin.json should be stale"
else
pass "plugin.json content drift (version bump) is detected"
bash "$SCRIPT" "$FIXTURE12" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
pass "re-sync clears the plugin.json drift"
else
fail "re-sync did not clear the plugin.json drift"
fi
fi
# --- 13. --check detects an orphaned hooks file after .apm/hooks/ is removed ---
echo ""
echo "--- --check detects an orphaned hooks/hooks.json when .apm/hooks/ is removed ---"
FIXTURE13="$(make_fixture)"; track "$FIXTURE13"
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
if [[ ! -f "$FIXTURE13/hooks/hooks.json" ]]; then
fail "initial sync did not create hooks/hooks.json -- can't test the orphan case"
fi
rm -rf "$FIXTURE13/.apm/hooks"
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
fail "no drift reported for an orphaned hooks/hooks.json after .apm/hooks/ removal"
else
pass "orphaned hooks/hooks.json is detected as drift"
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
if [[ ! -e "$FIXTURE13/hooks/hooks.json" ]]; then
pass "re-sync removes the orphaned hooks/hooks.json"
else
fail "re-sync left the orphaned hooks/hooks.json in place"
fi
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
pass "re-sync clears the orphaned-hooks drift"
else
fail "re-sync did not clear the orphaned-hooks drift"
fi
fi
# --- 14. A legacy root-level hooks.json is stale output, not content ---
# Every plugin synced by an earlier revision of this script carries one. Nothing
# reads it (no `hooks` pointer in the compiled plugin.json, and Claude Code's
# convention scan looks at hooks/hooks.json), so --check must flag it and a real
# sync must delete it.
echo ""
echo "--- a legacy root-level hooks.json is reported as drift and removed by a sync ---"
FIXTURE14="$(make_fixture)"; track "$FIXTURE14"
bash "$SCRIPT" "$FIXTURE14" > /dev/null 2>&1
printf '{"hooks": {"PreToolUse": []}}\n' > "$FIXTURE14/hooks.json"
if bash "$SCRIPT" --check "$FIXTURE14" > /dev/null 2>&1; then
fail "no drift reported for a leftover root-level hooks.json"
else
pass "a leftover root-level hooks.json is reported as drift"
bash "$SCRIPT" "$FIXTURE14" > /dev/null 2>&1
if [[ ! -e "$FIXTURE14/hooks.json" ]] && [[ -f "$FIXTURE14/hooks/hooks.json" ]]; then
pass "re-sync deletes the root-level hooks.json and keeps hooks/hooks.json"
else
fail "re-sync did not clean up the root-level hooks.json"
fi
fi
# --- 15. Deleting a skill from .apm/ leaves a stale mirror a re-sync must clear ---
# Without sync_dir()'s rm -rf of the destination before recopying, --check would
# report a drift that no amount of re-syncing could ever clear -- a permanently
# unfixable pre-push failure. This is the assertion that pins that wipe.
echo ""
echo "--- a skill deleted from .apm/ is removed from the mirror by a re-sync ---"
FIXTURE15="$(make_fixture)"; track "$FIXTURE15"
mkdir -p "$FIXTURE15/.apm/skills/doomed"
cat > "$FIXTURE15/.apm/skills/doomed/SKILL.md" <<'EOF'
---
name: doomed
description: doomed
---
Doomed.
EOF
bash "$SCRIPT" "$FIXTURE15" > /dev/null 2>&1
if [[ ! -f "$FIXTURE15/skills/doomed/SKILL.md" ]]; then
fail "initial sync did not mirror skills/doomed -- can't test the stale-skill case"
else
rm -rf "$FIXTURE15/.apm/skills/doomed"
if bash "$SCRIPT" --check "$FIXTURE15" > /dev/null 2>&1; then
fail "no drift reported for a mirrored skill deleted from .apm/"
else
pass "a mirrored skill deleted from .apm/ is reported as drift"
bash "$SCRIPT" "$FIXTURE15" > /dev/null 2>&1
if [[ ! -e "$FIXTURE15/skills/doomed" ]]; then
pass "re-sync removes the stale skills/doomed/ from the mirror"
else
fail "re-sync left the stale skills/doomed/ behind — this drift would be unfixable"
fi
if bash "$SCRIPT" --check "$FIXTURE15" > /dev/null 2>&1; then
pass "re-sync clears the stale-skill drift"
else
fail "re-sync did not clear the stale-skill drift"
fi
fi
fi
# --- 16. Drift in each of the less-obvious MIRROR_DIRS is detected ---
# agents/ and skills/ are exercised everywhere above; commands/, instructions/, and
# extensions/ were previously unreachable by the fixture, so dropping them from
# MIRROR_DIRS entirely still passed the suite.
echo ""
echo "--- drift in commands/, instructions/, and extensions/ is detected ---"
for CATEGORY_PATH in commands/mycmd.md instructions/style.instructions.md extensions/thing.md; do
FIXTURE16="$(make_fixture)"; track "$FIXTURE16"
bash "$SCRIPT" "$FIXTURE16" > /dev/null 2>&1
if [[ ! -f "$FIXTURE16/$CATEGORY_PATH" ]]; then
fail "sync did not mirror $CATEGORY_PATH at all — is its category still in MIRROR_DIRS?"
continue
fi
printf 'tampered\n' >> "$FIXTURE16/$CATEGORY_PATH"
if bash "$SCRIPT" --check "$FIXTURE16" > /dev/null 2>&1; then
fail "no drift reported after tampering with $CATEGORY_PATH"
else
pass "drift in $CATEGORY_PATH is detected"
fi
done
# --- 17. --check reports every drift in one run, not just the first ---
# The DRIFT-detail `diff | sed` pipelines return non-zero under `set -o pipefail`;
# without an explicit `|| true` guard, `set -e` aborts the per-plugin subshell after
# the first reported drift, turning one push into N fix/re-push cycles.
echo ""
echo "--- --check reports all independent drifts in a single run ---"
FIXTURE17="$(make_fixture)"; track "$FIXTURE17"
bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1
# Guarded like case 16's `[[ ! -f ... ]] || continue`, and for the same reason:
# an unwritable path here makes `printf >>` fail, and under `set -e` that aborts
# the whole script — no "Results:" line, and cases 18-22 never run at all. The
# exit status is non-zero so the dispatcher does report FAILED, but the six lost
# assertions are invisible and the only diagnostic is a bare shell error.
MISSING17=""
for CATEGORY_PATH in agents/foo.agent.md skills/hello/SKILL.md commands/mycmd.md \
instructions/style.instructions.md; do
[[ -f "$FIXTURE17/$CATEGORY_PATH" ]] || MISSING17="$MISSING17 $CATEGORY_PATH"
done
if [[ -n "$MISSING17" ]]; then
fail "sync did not mirror:$MISSING17 — cannot test multi-drift reporting"
else
printf 'tampered\n' >> "$FIXTURE17/agents/foo.agent.md"
printf 'tampered\n' >> "$FIXTURE17/skills/hello/SKILL.md"
printf 'tampered\n' >> "$FIXTURE17/commands/mycmd.md"
printf 'tampered\n' >> "$FIXTURE17/instructions/style.instructions.md"
mkdir -p "$FIXTURE17/hooks"
printf '{"hooks": {"PreToolUse": [], "tampered": true}}\n' > "$FIXTURE17/hooks/hooks.json"
CHECK17="$(bash "$SCRIPT" --check "$FIXTURE17" 2>&1 || true)"
MISSED=""
for CATEGORY_PATH in agents skills commands instructions hooks/hooks.json; do
case "$CHECK17" in
*"DRIFT $FIXTURE17/$CATEGORY_PATH"*) ;;
*) MISSED="$MISSED $CATEGORY_PATH" ;;
esac
done
if [[ -z "$MISSED" ]]; then
pass "all five independent drifts are reported in one --check run"
else
fail "--check stopped early — never reported drift for:$MISSED"
fi
fi
# --- 18. --check sees a mode change on a mirrored executable ---
# `diff -r` compares content only, so a chmod -x left --check at exit 0 while a real
# sync silently restored the bit — check and sync disagreeing.
echo ""
echo "--- --check detects a mode change on a mirrored executable ---"
FIXTURE18="$(make_fixture)"; track "$FIXTURE18"
bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1
if [[ ! -x "$FIXTURE18/skills/hello/scripts/run.sh" ]]; then
fail "sync did not preserve the executable bit on skills/hello/scripts/run.sh"
else
pass "sync preserves the executable bit on a mirrored script"
chmod -x "$FIXTURE18/skills/hello/scripts/run.sh"
if bash "$SCRIPT" --check "$FIXTURE18" > /dev/null 2>&1; then
fail "no drift reported after chmod -x on a mirrored executable"
else
pass "a mode change on a mirrored executable is detected as drift"
bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1
if [[ -x "$FIXTURE18/skills/hello/scripts/run.sh" ]]; then
pass "re-sync restores the executable bit"
else
fail "re-sync did not restore the executable bit"
fi
fi
fi
# --- 19. --check sees a mirrored file replaced by a symlink ---
# `diff -r` dereferences symlinks, so a symlink to byte-identical content reads as
# no drift while a real sync replaces it with a regular file.
echo ""
echo "--- --check detects a mirrored file swapped for a symlink ---"
FIXTURE19="$(make_fixture)"; track "$FIXTURE19"
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
SYMLINK_TARGET="$FIXTURE19/decoy-agent.md"
# Guarded for the same reason as cases 16 and 17: with the mirror absent the `cp`
# below fails and `set -e` takes the rest of the suite down with it.
if [[ ! -f "$FIXTURE19/agents/foo.agent.md" ]]; then
fail "sync did not mirror agents/foo.agent.md — cannot test the symlink-swap case"
else
cp "$FIXTURE19/agents/foo.agent.md" "$SYMLINK_TARGET"
rm -f "$FIXTURE19/agents/foo.agent.md"
ln -s "$SYMLINK_TARGET" "$FIXTURE19/agents/foo.agent.md"
if bash "$SCRIPT" --check "$FIXTURE19" > /dev/null 2>&1; then
fail "no drift reported after replacing a mirrored file with a symlink to identical content"
else
pass "a mirrored file replaced by a symlink is detected as drift"
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
if [[ -f "$FIXTURE19/agents/foo.agent.md" ]] && [[ ! -L "$FIXTURE19/agents/foo.agent.md" ]]; then
pass "re-sync restores it to a regular file"
else
fail "re-sync did not restore the symlinked mirror entry to a regular file"
fi
fi
fi
# --- 20. .apm/prompts/ is mirrored, via commands/ rather than a prompts/ of its own ---
# The script header lists prompts among the .apm/ directories it mirrors while
# MIRROR_DIRS has no `prompts` entry, which reads as a hole and has already been filed
# as one. It is not: MIRROR_DIRS names DESTINATION directories, and apm's exporter
# folds .apm/prompts/ into commands/ (renaming *.prompt.md to *.md) alongside
# .apm/commands/. This pins that mapping, so an apm upgrade that gave prompts a
# destination of its own — the one change that would genuinely need a MIRROR_DIRS
# entry — fails here instead of silently dropping the content.
echo ""
echo "--- .apm/prompts/ content arrives in commands/, not in a prompts/ directory ---"
FIXTURE20="$(make_fixture)"; track "$FIXTURE20"
bash "$SCRIPT" "$FIXTURE20" > /dev/null 2>&1
if [[ -f "$FIXTURE20/commands/greet.md" ]]; then
pass ".apm/prompts/greet.prompt.md is mirrored to commands/greet.md"
else
fail ".apm/prompts/ content never reached commands/ — apm's prompts mapping changed"
fi
if [[ ! -e "$FIXTURE20/prompts" ]]; then
pass "no prompts/ directory is produced at the plugin root"
else
fail "a prompts/ directory appeared at the plugin root — it now needs a MIRROR_DIRS entry"
fi
if bash "$SCRIPT" --check "$FIXTURE20" > /dev/null 2>&1; then
pass "--check is clean with .apm/prompts/ content present"
else
fail "--check reports drift on a freshly synced fixture carrying .apm/prompts/"
fi
# --- 21. A stray file inside the generated hooks/ directory is drift ---
# hooks/ is mirror-owned output, so it is scoped like a MIRROR_DIRS destination and
# not like the plugin root (where README.md, docs/, bin/ and .mcp.json are all
# hand-authored and deliberately none of this script's business). Before hooks/ came
# under that ownership, --check exited 0 on a stray inside it and a real sync left the
# stray untouched — agreeing with each other, but agreeing on the wrong answer.
echo ""
echo "--- a stray file inside the generated hooks/ directory is reported and removed ---"
FIXTURE21="$(make_fixture)"; track "$FIXTURE21"
bash "$SCRIPT" "$FIXTURE21" > /dev/null 2>&1
if [[ ! -f "$FIXTURE21/hooks/hooks.json" ]]; then
fail "initial sync did not create hooks/hooks.json -- can't test the stray case"
else
printf '{"stray": true}\n' > "$FIXTURE21/hooks/extra.json"
if bash "$SCRIPT" --check "$FIXTURE21" > /dev/null 2>&1; then
fail "no drift reported for a stray file inside the generated hooks/ directory"
else
pass "a stray file inside hooks/ is reported as drift"
bash "$SCRIPT" "$FIXTURE21" > /dev/null 2>&1
if [[ ! -e "$FIXTURE21/hooks/extra.json" ]] && [[ -f "$FIXTURE21/hooks/hooks.json" ]]; then
pass "re-sync removes the stray and keeps hooks/hooks.json"
else
fail "re-sync did not clean the stray out of hooks/"
fi
if bash "$SCRIPT" --check "$FIXTURE21" > /dev/null 2>&1; then
pass "re-sync clears the stray-hooks-file drift"
else
fail "re-sync did not clear the stray-hooks-file drift"
fi
fi
fi
# --- 22. An empty leftover hooks/ directory is drift, not an acceptable resting state ---
# When .apm/hooks/ produces no hooks.json, the correct mirror state is no hooks/
# directory at all — not an empty one. Empty leftovers are exactly what earlier
# revisions of this script left behind in this repo's own plugin roots.
echo ""
echo "--- an empty leftover hooks/ directory is reported and removed ---"
FIXTURE22="$(make_fixture)"; track "$FIXTURE22"
rm -rf "$FIXTURE22/.apm/hooks"
bash "$SCRIPT" "$FIXTURE22" > /dev/null 2>&1
if [[ -e "$FIXTURE22/hooks" ]]; then
fail "sync created hooks/ for a plugin whose .apm/hooks/ produces no hooks.json"
else
pass "no hooks/ directory when .apm/hooks/ produces nothing"
fi
mkdir -p "$FIXTURE22/hooks"
if bash "$SCRIPT" --check "$FIXTURE22" > /dev/null 2>&1; then
fail "no drift reported for an empty leftover hooks/ directory"
else
pass "an empty leftover hooks/ directory is reported as drift"
bash "$SCRIPT" "$FIXTURE22" > /dev/null 2>&1
if [[ ! -e "$FIXTURE22/hooks" ]]; then
pass "re-sync removes the empty leftover hooks/ directory"
else
fail "re-sync left the empty hooks/ directory in place"
fi
if bash "$SCRIPT" --check "$FIXTURE22" > /dev/null 2>&1; then
pass "re-sync clears the empty-hooks-directory drift"
else
fail "re-sync did not clear the empty-hooks-directory drift"
fi
fi
# --- 23. A credential in .mcp.json can never reach the published manifest ---
# apm's own builder runs _sanitize_mcp_servers() (apm_cli/core/plugin_manifest.py)
# before writing .claude-plugin/plugin.json — it drops env/environment/headers/
# authorization and any key matching token/secret/password/credential/apikey/key at
# any depth, because "copying them verbatim into a committed plugin.json would
# exfiltrate them into the distributed artefact". The old jq --slurpfile
# re-injection reached .github/plugin/plugin.json by a route that never touched the
# sanitizer, so the same fixture produced a stripped Claude manifest and a Copilot
# manifest carrying the live token. A path reference cannot carry a secret at all.
echo ""
echo "--- a token in .mcp.json never reaches .github/plugin/plugin.json ---"
# Assembled at runtime, never written as a literal: a credential-shaped constant
# in a tracked file is exactly what the gitleaks pre-commit hook exists to reject,
# and allowlisting this file to keep one would blunt the scanner across every
# future edit to it. The concatenation is what the leak test needs anyway — the
# assertion is that this value does not survive into the manifest, and its shape
# only has to be distinctive enough to grep for.
SECRET="ghp""_TESTONLYnotarealcredential000000000000"
FIXTURE23="$(make_fixture_with_mcp "{\"mcpServers\":{\"demo\":{\"command\":\"demo-server\",\"type\":\"stdio\",\"env\":{\"OBSIDIAN_API_TOKEN\":\"$SECRET\"}}}}")"; track "$FIXTURE23"
bash "$SCRIPT" "$FIXTURE23" > /dev/null 2>&1
if [[ ! -f "$FIXTURE23/.github/plugin/plugin.json" ]]; then
fail "sync produced no .github/plugin/plugin.json — cannot test the credential-leak case"
else
if grep -q "$SECRET" "$FIXTURE23/.github/plugin/plugin.json"; then
fail "the .mcp.json token was written into .github/plugin/plugin.json — a tracked, marketplace-distributed file"
else
pass "no .mcp.json credential appears in the generated Copilot manifest"
fi
# The Claude-side manifest is apm's own output and is sanitized upstream; assert
# it too, so this case fails loudly if a future change starts routing the Claude
# manifest through the same re-injection.
if [[ -f "$FIXTURE23/.claude-plugin/plugin.json" ]] \
&& grep -q "$SECRET" "$FIXTURE23/.claude-plugin/plugin.json"; then
fail "the .mcp.json token was written into .claude-plugin/plugin.json"
else
pass "no .mcp.json credential appears in the generated Claude manifest"
fi
if bash "$SCRIPT" --check "$FIXTURE23" > /dev/null 2>&1; then
pass "--check is clean on a freshly synced fixture whose .mcp.json carries an env block"
else
fail "--check reports drift on a freshly synced fixture carrying an .mcp.json env block"
fi
fi
# --- 24. .mcp.json drift is detected in both directions ---
# The re-injection is the only writer of the manifest's mcpServers field, and
# nothing covered it: --check could have silently stopped noticing either an
# .mcp.json that gained servers or one that lost them.
echo ""
echo "--- adding servers to .mcp.json after a sync is drift ---"
FIXTURE24="$(make_fixture_with_mcp '{"mcpServers":{}}')"; track "$FIXTURE24"
bash "$SCRIPT" "$FIXTURE24" > /dev/null 2>&1
if jq -e 'has("mcpServers")' "$FIXTURE24/.github/plugin/plugin.json" > /dev/null 2>&1; then
fail "an empty .mcp.json produced an mcpServers key — cannot test the gained-servers case"
else
printf '%s' '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}' > "$FIXTURE24/.mcp.json"
CHECK24="$(bash "$SCRIPT" --check "$FIXTURE24" 2>&1 || true)"
case "$CHECK24" in
*"DRIFT $FIXTURE24/.github/plugin/plugin.json"*)
pass "an .mcp.json that gained its first server is reported as drift" ;;
*)
fail "no drift reported for .github/plugin/plugin.json after .mcp.json gained a server" ;;
esac
bash "$SCRIPT" "$FIXTURE24" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE24" > /dev/null 2>&1; then
pass "re-sync clears the gained-server drift"
else
fail "re-sync did not clear the gained-server drift"
fi
fi
echo ""
echo "--- emptying .mcp.json after a sync is drift ---"
FIXTURE24B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE24B"
bash "$SCRIPT" "$FIXTURE24B" > /dev/null 2>&1
if ! jq -e '.mcpServers == ".mcp.json"' "$FIXTURE24B/.github/plugin/plugin.json" > /dev/null 2>&1; then
fail "initial sync did not re-inject mcpServers — cannot test the lost-servers case"
else
printf '%s' '{"mcpServers":{}}' > "$FIXTURE24B/.mcp.json"
CHECK24B="$(bash "$SCRIPT" --check "$FIXTURE24B" 2>&1 || true)"
case "$CHECK24B" in
*"DRIFT $FIXTURE24B/.github/plugin/plugin.json"*)
pass "an .mcp.json emptied of its servers is reported as drift" ;;
*)
fail "no drift reported for .github/plugin/plugin.json after .mcp.json lost its servers" ;;
esac
bash "$SCRIPT" "$FIXTURE24B" > /dev/null 2>&1
if jq -e 'has("mcpServers") | not' "$FIXTURE24B/.github/plugin/plugin.json" > /dev/null 2>&1 \
&& bash "$SCRIPT" --check "$FIXTURE24B" > /dev/null 2>&1; then
pass "re-sync drops the mcpServers key and clears the drift"
else
fail "re-sync did not drop mcpServers / did not clear the lost-server drift"
fi
fi
# --- 25. The generated manifests: --check and a real sync agree about their mode ---
# The gate's contract is agreement between --check and a real sync, not "every
# property is repaired". Neither touches a generated manifest's permission bits, and
# neither can: in check mode the expected side is a `cp -a` of the real plugin root,
# so apm pack rewrites a file whose mode is already the actual side's. A revision that
# listed these paths in the mode manifest was measuring that inheritance, not the
# mirror — `chmod 600` left --check at exit 0 for as long as it was listed. This case
# pins the agreement instead, so a future "fix" that makes --check report a mode it
# cannot repair fails here.
echo ""
echo "--- --check and a real sync agree that a manifest's mode is not theirs to change ---"
FIXTURE25="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25"
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
if ! bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
fail "check reported drift right after the initial sync — cannot test the manifest-mode case"
else
chmod 600 "$FIXTURE25/.github/plugin/plugin.json"
if bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
MODE25="$(mode_of "$FIXTURE25/.github/plugin/plugin.json")"
if [[ "$MODE25" == "600" ]]; then
pass "--check reports no mode drift on a manifest, and a real sync indeed leaves the mode alone"
else
fail "--check reported no mode drift but a real sync changed the mode to $MODE25 — check and sync disagree"
fi
else
fail "--check reported drift after chmod 600 on .github/plugin/plugin.json, but a real sync cannot repair it — an unfixable pre-push failure"
fi
fi
# --- 25b. A manifest replaced by a SYMLINK is real drift and is reported ---
# This is the one property of the generated manifests worth asserting, and the reason
# it cannot live in check_path_modes: that compares against a `cp -a` of the same
# plugin root, which reproduces the symlink on the expected side and calls the two
# equal (verified — it sat at exit 0). The hazard is concrete: apm pack opens the
# manifest for writing and reinject_mcp_servers redirects into it, and both follow the
# link, so a real sync rewrites the link's TARGET instead of the manifest.
echo ""
echo "--- a plugin.json replaced by a symlink is reported as drift ---"
FIXTURE25B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25B"
bash "$SCRIPT" "$FIXTURE25B" > /dev/null 2>&1
if [[ ! -f "$FIXTURE25B/.claude-plugin/plugin.json" ]]; then
fail "sync produced no .claude-plugin/plugin.json — cannot test the symlinked-manifest case"
else
cp "$FIXTURE25B/.claude-plugin/plugin.json" "$FIXTURE25B/decoy.json"
rm -f "$FIXTURE25B/.claude-plugin/plugin.json"
ln -s ../decoy.json "$FIXTURE25B/.claude-plugin/plugin.json"
CHECK25B="$(bash "$SCRIPT" --check "$FIXTURE25B" 2>&1 || true)"
case "$CHECK25B" in
*"DRIFT $FIXTURE25B/.claude-plugin/plugin.json: is a symlink"*)
pass "a symlinked .claude-plugin/plugin.json is reported as drift" ;;
*)
fail "no drift reported for a symlinked .claude-plugin/plugin.json — a real sync would write through it. Output: $CHECK25B" ;;
esac
fi
# --- 26. --check compares full permission bits, not just the exec bit ---
# The manifest used to record a bare exec/file kind, so `chmod 444` on a mirrored
# SKILL.md left --check at exit 0 while a real sync restored 644 — the same
# check/sync disagreement case 18 pins for the exec bit, one bit over.
echo ""
echo "--- --check detects a non-exec permission change on a mirrored file ---"
FIXTURE26="$(make_fixture)"; track "$FIXTURE26"
bash "$SCRIPT" "$FIXTURE26" > /dev/null 2>&1
if [[ ! -f "$FIXTURE26/skills/hello/SKILL.md" ]]; then
fail "sync did not mirror skills/hello/SKILL.md — cannot test the permission-bits case"
else
chmod 444 "$FIXTURE26/skills/hello/SKILL.md"
CHECK26="$(bash "$SCRIPT" --check "$FIXTURE26" 2>&1 || true)"
case "$CHECK26" in
*"mirrored paths/types/modes differ"*)
pass "chmod 444 on a mirrored file is reported as drift" ;;
*)
fail "no drift reported after chmod 444 on a mirrored file — only the exec bit is being compared" ;;
esac
bash "$SCRIPT" "$FIXTURE26" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE26" > /dev/null 2>&1; then
pass "re-sync restores the permission bits and clears the drift"
else
fail "re-sync did not clear the permission-bits drift"
fi
fi
# --- 26b. The mode comparison must not depend on the runtime umask ---
# Case 26's widening from the exec bit to full permission bits is correct for the
# files this script COPIES — both sides of the comparison trace to the same checkout.
# It is wrong for the files it WRITES: sync_hooks_json creates hooks/hooks.json with
# `printf '%s\n' >`, at the RUNTIME umask, while the real side carries the umask of
# the checkout that produced the committed file. Those are independent, so on a
# umask-002 machine `--check --all` over this repo's own umask-022 checkout reported
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks — a pre-push
# failure with nothing wrong, and unfixable by committing, since git records no
# non-exec mode and the next --check from a umask-022 machine fails the other way.
#
# Two directions, because a one-sided assertion passes on the wrong fix: (a) the same
# tree checked under several runtime umasks, and (b) a tree whose GENERATED files carry
# a foreign umask — which is exactly what a umask-002 clone of a umask-022 commit looks
# like, git having recorded nothing to distinguish them.
echo ""
echo "--- --check is umask-independent over the files this script generates ---"
FIXTURE26B="$(umask 022; make_fixture)"; track "$FIXTURE26B"
(umask 022; bash "$SCRIPT" "$FIXTURE26B" > /dev/null 2>&1)
if [[ ! -f "$FIXTURE26B/hooks/hooks.json" ]]; then
fail "sync did not create hooks/hooks.json — cannot test the umask-independence case"
else
for UMASK26B in 022 002 077; do
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
pass "--check at umask $UMASK26B is clean on a tree synced at umask 022"
else
fail "--check at umask $UMASK26B reported drift on a tree synced at umask 022 — the gate is reporting the runner's umask, not the mirror"
fi
done
# What a umask-002 clone of the same commit looks like on disk.
chmod 664 "$FIXTURE26B/hooks/hooks.json"
[[ -f "$FIXTURE26B/.claude-plugin/plugin.json" ]] && chmod 664 "$FIXTURE26B/.claude-plugin/plugin.json"
for UMASK26B in 022 002; do
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
pass "--check at umask $UMASK26B is clean when the generated files carry a umask-002 checkout's mode"
else
fail "--check at umask $UMASK26B reported drift on generated files carrying a umask-002 checkout's mode — no commit can fix that"
fi
done
fi
# --- 27. A plugin-dir argument whose basename is . or .. is rejected ---
# Every scratch path is "$SCRATCH_ROOT/$(basename "$plugin_dir")", so `..` resolves
# to the scratch root's PARENT: `apm pack -o` then writes outside the tree the EXIT
# trap cleans, and sync_one's `find "$scratch" -mindepth 1 -maxdepth 1 -type d |
# head -1` adopts an arbitrary unrelated directory as the "bundle" — whose contents
# a real sync cp -a's into the plugin root after an rm -rf. The duplicate-basename
# guard cannot catch it: a single `..` collides with nothing.
echo ""
echo "--- a plugin dir whose basename is . or .. is rejected ---"
for TRAVERSAL in . ..; do
RC27=0
OUT27="$(bash "$SCRIPT" "$TRAVERSAL" 2>&1)" || RC27=$?
case "$RC27:$OUT27" in
0:*)
fail "'$TRAVERSAL' was accepted as a plugin dir — expected a rejection" ;;
*"scratch paths built from it would escape the scratch root"*)
pass "'$TRAVERSAL' is rejected with a scratch-path-escape error" ;;
*)
fail "'$TRAVERSAL' was not rejected with the expected message (rc=$RC27): $OUT27" ;;
esac
done
# --- 27b. sync_dir and sync_hooks_json refuse an empty target_dir ---
# `set -u` aborts on an UNSET variable but not an empty one, so an empty
# $target_dir turns both functions' `rm -rf` calls into `rm -rf /agents`,
# `/skills`, `/commands`, `/instructions`, `/extensions`, `/hooks`. Unreachable
# from today's two call sites (both pass a validated plugin_dir or a scratch
# path), which is exactly why it needs a direct test: no end-to-end invocation
# can reach it, and the next caller added is the one that finds out.
#
# The functions are extracted and run with rm/mkdir/cp/find shadowed by loggers,
# so the UNGUARDED form is observed rather than executed — running it for real,
# as root, is the outcome the guard exists to prevent.
echo ""
echo "--- sync_dir/sync_hooks_json abort on an empty target_dir instead of rm -rf'ing / ---"
for GUARDED_FN in sync_dir sync_hooks_json; do
FNSRC="$(awk -v fn="^${GUARDED_FN}\\\\(\\\\) \\\\{$" '$0 ~ fn, /^\}$/' "$SCRIPT")"
if [[ -z "$FNSRC" ]] || [[ "$FNSRC" != *"rm -rf"* ]]; then
fail "could not extract $GUARDED_FN() from $SCRIPT — this case is testing nothing"
continue
fi
GUARD_BUNDLE="$(mktemp -d)"; track "$GUARD_BUNDLE"
mkdir -p "$GUARD_BUNDLE/agents"
printf '{}\n' > "$GUARD_BUNDLE/hooks.json"
GUARD_LOG="$(mktemp)"; track "$GUARD_LOG"
# HOOKS_DIR_REL/HOOKS_REL/LEGACY_HOOKS_REL are script globals sync_hooks_json
# reads; supply them so the extracted copy behaves like the real one.
RC27B=0
bash -c '
set -euo pipefail
LOG="$2"
HOOKS_DIR_REL="hooks"; HOOKS_REL="hooks/hooks.json"; LEGACY_HOOKS_REL="hooks.json"
rm() { printf "rm %s\n" "$*" >> "$LOG"; }
mkdir() { printf "mkdir %s\n" "$*" >> "$LOG"; }
cp() { printf "cp %s\n" "$*" >> "$LOG"; }
find() { printf "find %s\n" "$*" >> "$LOG"; }
'"$FNSRC"'
'"$GUARDED_FN"' "" "$1" agents
' _ "$GUARD_BUNDLE" "$GUARD_LOG" > /dev/null 2>&1 || RC27B=$?
DANGEROUS="$(grep -E '(^| )/(agents|skills|commands|instructions|extensions|hooks)([[:space:]]|$)' "$GUARD_LOG" 2>/dev/null || true)"
if [[ "$RC27B" -ne 0 ]] && [[ -z "$DANGEROUS" ]]; then
pass "$GUARDED_FN aborts on an empty target_dir before touching any path"
else
fail "$GUARDED_FN with an empty target_dir exited $RC27B and would have run:${DANGEROUS:-<nothing logged>}"
fi
done
# --- 28. --all refuses to report success over an unusable or empty marketplace ---
# check-plugin-content-sync is the one pre-push gate whose work list comes from a
# GENERATED file, so "the marketplace yields nothing" must never mean "verified,
# no drift" — regenerating marketplace.json badly would otherwise silence the hook
# that guards it. Every case below reached exit 0 before: the process substitution
# feeding `while read` is its own subshell, so a jq abort in there yields zero lines
# and reads exactly like "declares no local plugins".
echo ""
echo "--- --check --all fails on a marketplace that yields no plugins ---"
make_repo_fixture() {
local marketplace_json="$1" dir
dir="$(mktemp -d)"
dir="$(cd "$dir" && pwd -P)"
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
echo "make_repo_fixture: 'git init' failed in $dir" >&2
exit 1
fi
mkdir -p "$dir/.claude-plugin"
printf '%s' "$marketplace_json" > "$dir/.claude-plugin/marketplace.json"
echo "$dir"
}
# `env -u GIT_DIR -u GIT_WORK_TREE` for the same reason
# tests/test-sync-marketplace-mirror.sh does it: run-tests.sh runs as a pre-push
# hook, and git hooks export both variables, which would re-target the script's
# `git rev-parse --show-toplevel` at the LIVE repo from any cwd.
run_all() {
local dir="$1"
shift
(cd "$dir" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" "$@")
}
# Message-asserted, not just exit-code-asserted: --all has several independent
# routes to exit 1 (missing marketplace, apm pack failure, genuine drift), and an
# exit-code-only assertion would pass on any of them.
check_all_fails_with() {
local desc="$1" marketplace_json="$2" expected="$3" dir out rc=0
dir="$(make_repo_fixture "$marketplace_json")"; track "$dir"
out="$(run_all "$dir" --check --all 2>&1)" || rc=$?
if [[ "$rc" -eq 0 ]]; then
fail "$desc: --check --all exited 0 having checked nothing"
else
case "$out" in
*"$expected"*) pass "$desc: rejected with the expected message" ;;
*) fail "$desc: exited non-zero but not for the expected reason: $out" ;;
esac
fi
}
check_all_fails_with "an empty plugins array" \
'{"plugins":[]}' \
"declares no local (string-source) plugin entries"
check_all_fails_with "a marketplace with no plugins key at all" \
'{"name":"x"}' \
"declares no local (string-source) plugin entries"
check_all_fails_with "unparseable JSON" \
'{ not json' \
"is not valid JSON"
check_all_fails_with "a plugins field that is not an array" \
'{"plugins":{"a":1}}' \
"expected an array of plugin entries"
check_all_fails_with "a JSON array at the marketplace root" \
'[]' \
"is a JSON array at its top level"
check_all_fails_with "an entry with no source field" \
'{"plugins":[{"name":"orphan"}]}' \
"\`source\` is neither a local path string nor a remote source object"
# The guard used to name `.source == null` specifically, so every other malformed
# value walked straight through it into the same silence.
check_all_fails_with "an entry whose source is a number" \
'{"plugins":[{"name":"orphan","source":42}]}' \
"\`source\` is neither a local path string nor a remote source object"
check_all_fails_with "an entry whose source is an array" \
'{"plugins":[{"name":"orphan","source":[]}]}' \
"\`source\` is neither a local path string nor a remote source object"
# --- 28b. --all outside a git worktree refuses instead of guessing $PWD ---
# --all's entire work list hangs off REPO_ROOT, so a `|| pwd` fallback lets it derive
# that list from a marketplace.json belonging to some other tree. Same reasoning
# scripts/sync-marketplace-mirror.sh dropped its own fallback on. Run from a directory
# with no marketplace.json the old form happened to hit the "--all requires ..." error,
# but only by accident — the dangerous case is a $PWD that HAS one.
echo ""
echo "--- --all outside a git worktree refuses to guess the repository root ---"
NOGIT="$(mktemp -d)"; track "$NOGIT"
mkdir -p "$NOGIT/.claude-plugin"
printf '%s' '{"plugins":[{"name":"decoy","source":"./plugins/decoy"}]}' > "$NOGIT/.claude-plugin/marketplace.json"
if (cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
fail "fixture precondition: $NOGIT is inside a git worktree, so this case cannot test the no-worktree path"
else
RC28B=0
OUT28B="$(cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC28B=$?
case "$RC28B:$OUT28B" in
0:*)
fail "--check --all exited 0 outside a worktree, having derived its plugin list from \$PWD" ;;
*"not inside a git worktree"*)
pass "--all refuses to guess \$PWD when it cannot locate the repository root" ;;
*)
fail "--check --all exited $RC28B outside a worktree but not for the stated reason: $OUT28B" ;;
esac
fi
# --- 29. A symlink under .apm/ is reported, in both modes ---
# apm's bundle exporter filters every symlink out of the bundle it builds
# (`f.is_file() and not f.is_symlink()` in _collect_flat/_collect_recursive,
# apm_cli/bundle/plugin_exporter.py) with no warning. Every other check here diffs
# the live mirror against a freshly synced copy, and BOTH are built from that same
# bundle — so the symlink is absent on both sides, they agree, and --check exits 0
# while the author's content is simply gone. Not a mismatch: an absence with nothing
# left to mismatch against. Verified before the fix: `ln -s real.md link.md` under
# .apm/skills/hello/ produced a mirror with no link.md and a --check at exit 0.
#
# Message-asserted, not exit-code-asserted: an unsynced fixture exits 1 anyway, so a
# bare non-zero would pass with the detection deleted.
echo ""
echo "--- a symlink under .apm/ is reported as lost content in both modes ---"
FIXTURE29="$(make_fixture)"; track "$FIXTURE29"
printf 'real content\n' > "$FIXTURE29/.apm/skills/hello/real.md"
ln -s real.md "$FIXTURE29/.apm/skills/hello/link.md"
RCSYNC29=0
SYNC29="$(bash "$SCRIPT" "$FIXTURE29" 2>&1)" || RCSYNC29=$?
CHECK29="$(bash "$SCRIPT" --check "$FIXTURE29" 2>&1 || true)"
for MODE29 in sync check; do
case "$MODE29" in
sync) OUT29="$SYNC29" ;;
check) OUT29="$CHECK29" ;;
esac
case "$OUT29" in
*"$FIXTURE29/.apm/skills/hello/link.md: symlink under .apm/"*)
pass "$MODE29 mode reports the symlink under .apm/ by path" ;;
*)
fail "$MODE29 mode did not report the symlink under .apm/ — apm drops it silently and no diff can see it: $OUT29" ;;
esac
done
# The loss is real, not theoretical: assert the mirror genuinely lacks it, so this
# case still means something if apm ever starts exporting symlinks.
if [[ ! -e "$FIXTURE29/skills/hello/link.md" ]]; then
pass "the symlink is indeed absent from the mirror (nothing else could have caught it)"
else
fail "the symlink reached the mirror — apm's exporter no longer drops it, so this report is now wrong"
fi
# The real sync above exited non-zero too (its rc, not a fresh run). Sync and --check
# agreeing is this script's core contract, and a real sync that "succeeds" while
# dropping content breaks it.
if [[ "$RCSYNC29" -ne 0 ]]; then
pass "a real sync exits non-zero rather than reporting success over dropped content"
else
fail "a real sync exited 0 while silently dropping .apm/ content — sync and --check must agree"
fi
rm -f "$FIXTURE29/.apm/skills/hello/link.md"
if bash "$SCRIPT" "$FIXTURE29" > /dev/null 2>&1 && bash "$SCRIPT" --check "$FIXTURE29" > /dev/null 2>&1; then
pass "removing the symlink clears the report in both modes"
else
fail "the symlink report survived its removal"
fi
# --- 29b. The report is scoped to content the mirror would actually carry ---
# sync_dir strips <category>/<name>/tests from the mirror outright, so a symlink in
# there loses nothing and reporting it would be a false alarm demanding a pointless
# edit. A `tests` DEEPER than that is a template asset the mirror does carry (case
# 3b), so a symlink in it is real loss. Same depth boundary, both directions —
# a carve-out asserted in only one direction passes on "report nothing, ever".
echo ""
echo "--- the symlink report follows the mirror's own tests/ depth boundary ---"
FIXTURE29B="$(make_fixture)"; track "$FIXTURE29B"
printf 'x\n' > "$FIXTURE29B/.apm/skills/hello/tests/real.txt"
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/tests/link.txt"
bash "$SCRIPT" "$FIXTURE29B" > /dev/null 2>&1
RC29B=0
OUT29B="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1)" || RC29B=$?
case "$OUT29B" in
*"tests/link.txt: symlink under .apm/"*)
fail "a symlink under the un-mirrored <name>/tests/ was reported — nothing is lost there" ;;
*)
pass "a symlink under <name>/tests/ is not reported (that subtree is not mirrored)" ;;
esac
# Exit code as well as message, from that same run: a carve-out that suppresses the
# line but still fails the gate is not a carve-out.
if [[ "$RC29B" -eq 0 ]]; then
pass "--check is clean with a symlink confined to the un-mirrored tests/ fixture dir"
else
fail "--check reported drift for a symlink under the un-mirrored <name>/tests/: $OUT29B"
fi
printf 'y\n' > "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/real.txt"
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/link.txt"
OUT29B2="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1 || true)"
case "$OUT29B2" in
*"assets/templates/tests/link.txt: symlink under .apm/"*)
pass "a symlink under the mirrored assets/templates/tests/ IS reported" ;;
*)
fail "a symlink under the mirrored assets/templates/tests/ was not reported — the carve-out is depth-agnostic and swallows real loss: $OUT29B2" ;;
esac
# --- 30. --all fails when it verified fewer plugins than the marketplace declares ---
# The zero-plugin floor (case 28) rejects "the marketplace yielded nothing"; it cannot
# see "it yielded N and only M were checked". sync_one SKIPs a plugin directory with no
# .apm/ at status 0, so --all printed one SKIP line and exited 0 having verified one
# plugin fewer than it listed — a pre-push gate over a GENERATED work list silently
# covering less than it claims.
echo ""
echo "--- --check --all fails when a listed plugin cannot be verified ---"
# A repo fixture with real plugin directories, unlike case 28's marketplace-only one.
make_repo_with_plugins() {
local dir
dir="$(mktemp -d)"
dir="$(cd "$dir" && pwd -P)"
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
echo "make_repo_with_plugins: 'git init' failed in $dir" >&2
exit 1
fi
mkdir -p "$dir/.claude-plugin" "$dir/plugins"
printf '%s' '{"plugins":[{"name":"good","source":"./plugins/good"},{"name":"bare","source":"./plugins/bare"}]}' \
> "$dir/.claude-plugin/marketplace.json"
local p
for p in good bare; do
local src
src="$(make_fixture)"; track "$src"
mv "$src" "$dir/plugins/$p"
done
echo "$dir"
}
REPO30="$(make_repo_with_plugins)"; track "$REPO30"
# Sync both first, so the ONLY thing --all can complain about below is the count.
bash "$SCRIPT" "$REPO30/plugins/good" "$REPO30/plugins/bare" > /dev/null 2>&1
if (cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all > /dev/null 2>&1); then
pass "--check --all is clean when every declared plugin is verifiable (baseline)"
else
fail "--check --all reported drift on a freshly synced two-plugin repo — cannot test the count case"
fi
# Now take one listed plugin's .apm/ away: it is still declared, still on disk, and
# now unverifiable. Its mirror is left in place, so no other check has anything to say.
rm -rf "$REPO30/plugins/bare/.apm"
RC30=0
OUT30="$(cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC30=$?
case "$RC30:$OUT30" in
0:*)
fail "--check --all exited 0 having verified 1 of the 2 plugins its marketplace declares" ;;
*"verified 1 of the 2 local plugin entries"*)
pass "--check --all fails and names how many of the declared plugins it actually verified" ;;
*)
fail "--check --all exited $RC30 but not for the under-count reason: $OUT30" ;;
esac
# The message must name the plugin, not just the arithmetic — a count alone leaves the
# reader diffing marketplace.json against a directory listing by hand.
case "$OUT30" in
*"unverified: $REPO30/plugins/bare"*)
pass "the failure names the unverified plugin directory" ;;
*)
fail "the failure did not name the unverified plugin directory: $OUT30" ;;
esac
# ...and an explicitly-named plugin dir with no .apm/ stays a skip (case 6): there the
# caller chose the work list, so a non-apm directory is their business, not drift in a
# generated file.
if bash "$SCRIPT" --check "$REPO30/plugins/bare" > /dev/null 2>&1; then
pass "the same directory named explicitly is still a clean skip, not a failure"
else
fail "an explicitly-named plugin dir with no .apm/ now fails — case 6's skip contract is broken"
fi
# --- 31. No `hooks` pointer is injected into the Copilot manifest ---
# Copilot types `hooks` "string or object" with NO default (github-copilot-plugins/
# configuration.md:47), exactly like mcpServers — so Copilot resolves no hooks from any
# plugin here, and re-injecting a pointer the way reinject_mcp_servers() does for
# mcpServers looks like the obvious twin fix. It is not, and this case pins the
# difference: apm merges .apm/hooks/*.json into exactly ONE hooks.json with no
# per-target shaping, while the two ecosystems' hook file formats are mutually
# incompatible (Claude: `{"hooks":{"PreToolUse":[{matcher,hooks}]}}`; Copilot:
# `{"version":1,"hooks":{"sessionStart":[{type,bash,powershell}]}}`). A pointer would
# assert that a Claude-shaped file is Copilot-shaped — a wrong manifest in place of an
# incomplete one. .mcp.json carries no such claim: it is one format both hosts read.
# See ADR-0017's "no `hooks` pointer" amendment and plugins/kyberforge/docs/hooks.md.
echo ""
echo "--- the generated Copilot manifest carries no hooks pointer ---"
FIXTURE31="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE31"
mkdir -p "$FIXTURE31/.apm/hooks"
cat > "$FIXTURE31/.apm/hooks/hooks.json" <<'EOF'
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "true"}]}]}}
EOF
bash "$SCRIPT" "$FIXTURE31" > /dev/null 2>&1
if [[ ! -f "$FIXTURE31/hooks/hooks.json" ]]; then
fail "sync produced no hooks/hooks.json — cannot test the hooks-pointer decision"
elif [[ ! -f "$FIXTURE31/.github/plugin/plugin.json" ]]; then
fail "sync produced no .github/plugin/plugin.json — cannot test the hooks-pointer decision"
else
pass "a non-empty .apm/hooks/ produces hooks/hooks.json (Claude Code's convention path)"
if jq -e 'has("hooks") | not' "$FIXTURE31/.github/plugin/plugin.json" > /dev/null 2>&1; then
pass "no hooks pointer in .github/plugin/plugin.json, even with a real hook present"
else
fail "a hooks pointer was injected into the Copilot manifest (got: $(jq -c '.hooks' "$FIXTURE31/.github/plugin/plugin.json" 2>/dev/null)) — it would point Copilot at a Claude-shaped hooks file. Reconcile the two hook schemas first; see ADR-0017"
fi
# The Claude manifest needs none either: hooks/hooks.json IS its convention path.
if jq -e 'has("hooks") | not' "$FIXTURE31/.claude-plugin/plugin.json" > /dev/null 2>&1; then
pass "no hooks pointer in .claude-plugin/plugin.json either — the convention path needs none"
else
fail "a hooks pointer appeared in the Claude manifest, which convention-scans hooks/hooks.json already"
fi
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]