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
1292 lines
59 KiB
Bash
Executable File
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 ]]
|