From fd70c8d65e0a85e2878e8e71ad850c9b8f84a3c8 Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Thu, 13 Aug 2026 22:12:04 +0000 Subject: [PATCH] fix(kyberforge): catch plugin.json and hooks.json drift in sync-plugin-content.sh --check --check's throwaway pack copy seeded .claude-plugin/plugin.json and .github/plugin/plugin.json from the real plugin dir, then packed without --force -- apm pack silently skips regenerating a plugin.json that already exists, so the diff always compared the copy against itself and never caught drift in the compiled name/version/description/mcpServers. --force is now always passed; in check mode it forces regeneration inside the throwaway copy only, which sync_plugin_manifest() then diffs against the real committed manifest. sync_hooks_json() returned early whenever .apm/hooks/ was missing, without checking whether a stale hooks.json was still sitting at the plugin root from a prior sync -- unlike sync_dir(), which already detects that kind of orphaned mirrored output. It now mirrors sync_dir()'s shape: flagged as drift in --check, removed on a real sync. Running the corrected --check --all against this repo's own plugins surfaced 3 real orphans: plugins/{git,gitea,core}/hooks.json, empty stubs added in 4edaaac only to satisfy an old plugin.json pointer-field check that no longer exists (their compiled plugin.json has never had a hooks field, and none of the three ever had .apm/hooks/). Removed as part of this fix since they're exactly the drift the corrected check now catches -- leaving them would break the sync-plugin-content pre-push gate on this branch. Also extracts two shared helpers into scripts/lib/, sourced by this script and others so a future bug fix doesn't need hand-applying three times: - marketplace-plugins.sh: walks marketplace.json for local plugin dirs (this script's --all branch and check-manifests.sh had near-identical copies) - batch-run.sh: the bounded-batch concurrent job runner (this script, tests/run-tests.sh, and tests/run-bats.sh each hand-rolled the same core-count-capped wait loop independently) Extended tests/test-sync-plugin-content.sh with coverage for both drift cases (plugin.json version-bump drift, orphaned-hooks.json drift), including that a re-sync clears each. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT --- plugins/core/hooks.json | 3 - plugins/git/hooks.json | 3 - plugins/gitea/hooks.json | 3 - scripts/lib/batch-run.sh | 53 ++++++++++ scripts/lib/marketplace-plugins.sh | 28 ++++++ scripts/sync-plugin-content.sh | 156 +++++++++++++++++++---------- tests/run-bats.sh | 25 ++--- tests/run-tests.sh | 30 ++---- tests/test-sync-plugin-content.sh | 67 +++++++++++++ 9 files changed, 271 insertions(+), 97 deletions(-) delete mode 100644 plugins/core/hooks.json delete mode 100644 plugins/git/hooks.json delete mode 100644 plugins/gitea/hooks.json create mode 100644 scripts/lib/batch-run.sh create mode 100644 scripts/lib/marketplace-plugins.sh diff --git a/plugins/core/hooks.json b/plugins/core/hooks.json deleted file mode 100644 index deffac9..0000000 --- a/plugins/core/hooks.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hooks": {} -} diff --git a/plugins/git/hooks.json b/plugins/git/hooks.json deleted file mode 100644 index deffac9..0000000 --- a/plugins/git/hooks.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hooks": {} -} diff --git a/plugins/gitea/hooks.json b/plugins/gitea/hooks.json deleted file mode 100644 index deffac9..0000000 --- a/plugins/gitea/hooks.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hooks": {} -} diff --git a/scripts/lib/batch-run.sh b/scripts/lib/batch-run.sh new file mode 100644 index 0000000..a1612f0 --- /dev/null +++ b/scripts/lib/batch-run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Shared bounded-batch concurrent job runner. Sourced by +# scripts/sync-plugin-content.sh, tests/run-tests.sh, and tests/run-bats.sh so +# their concurrency-cap and per-item log/status handling can't silently +# diverge -- previously the same batching logic (core-count cap, per-item +# log/status files, batched `wait`) was hand-implemented independently in all +# three places. +# +# Batches (not a rolling pool) because a bounded rolling pool needs `wait -n`, +# which is bash 4.3+ -- all three callers are explicitly bash-3.2-safe. +# `getconf` over `nproc` for the same reason: `nproc` doesn't exist on macOS. +# +# Not meant to be executed directly -- source it. + +# batch_jobs_limit +# Prints the concurrency cap to use for batching. +batch_jobs_limit() { + getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4 +} + +# batch_run [ ...] +# +# For each key/cmd pair, backgrounds `eval "$cmd"` with its combined +# stdout+stderr redirected to "/.log", bounded to at most +# batch_jobs_limit concurrent jobs (waiting out the current batch before +# starting the next). +# +# Each owns writing its own result to "/.status" -- +# this helper only owns dispatch/throttling and log capture, not status +# semantics. Callers differ on how they do that (capturing $? of an external +# command with `|| rc=$?`, or a sync function writing its own status flag +# directly) -- both patterns are preserved as-is by callers, not standardized +# here, so existing error-handling behavior (including how each pattern +# interacts with `set -e` in the caller) is unchanged by this extraction. +batch_run() { + local scratch_dir="$1" + shift + local jobs_limit running key cmd + jobs_limit="$(batch_jobs_limit)" + running=0 + + while [[ $# -gt 0 ]]; do + key="$1" cmd="$2" + shift 2 + (eval "$cmd") >"$scratch_dir/$key.log" 2>&1 & + running=$((running + 1)) + if [[ $running -ge $jobs_limit ]]; then + wait + running=0 + fi + done + wait +} diff --git a/scripts/lib/marketplace-plugins.sh b/scripts/lib/marketplace-plugins.sh new file mode 100644 index 0000000..a6c4128 --- /dev/null +++ b/scripts/lib/marketplace-plugins.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Shared helper: enumerates the local (string-source) plugin entries declared in +# .claude-plugin/marketplace.json. Sourced by scripts/sync-plugin-content.sh +# (--all) and scripts/check-manifests.sh so a future marketplace.json schema +# change (e.g. a new source type) only has to be handled in one place instead +# of drifting between two hand-maintained copies of the same walk. +# +# Requires jq. Not meant to be executed directly -- source it. + +# list_marketplace_local_plugins +# +# Prints one "\t" line per local (string `source:`) +# marketplace entry. Remote sources (github/git/npm objects) have no local +# directory to walk and are skipped, matching both callers' prior behavior. +list_marketplace_local_plugins() { + local repo_root="$1" marketplace="$2" + local plugin_count i name source_type source + + plugin_count="$(jq '.plugins | length' "$marketplace")" + for ((i = 0; i < plugin_count; i++)); do + source_type="$(jq -r ".plugins[$i].source | type" "$marketplace")" + [[ "$source_type" == "string" ]] || continue + name="$(jq -r ".plugins[$i].name" "$marketplace")" + source="$(jq -r ".plugins[$i].source" "$marketplace")" + source="${source#./}" + printf '%s\t%s\n' "$name" "$repo_root/$source" + done +} diff --git a/scripts/sync-plugin-content.sh b/scripts/sync-plugin-content.sh index 6949ae5..60ed58e 100755 --- a/scripts/sync-plugin-content.sh +++ b/scripts/sync-plugin-content.sh @@ -18,28 +18,34 @@ set -euo pipefail # generated in-place at the plugin root by a separate apm code path # (core/plugin_manifest.py, run as part of the same `apm pack` invocation, keyed off # cwd rather than -o), and .mcp.json is hand-authored at the plugin root per ADR-0015 -# (it is not an .apm/ primitive). Real-mode syncs pass --force so that path actually -# refreshes both files from current apm.yml/.apm/ content -- apm pack silently skips -# regenerating an existing plugin.json otherwise ("already exists; skipping plugin.json -# generation"), which would let them go stale after a name/version/description edit. +# (it is not an .apm/ primitive). Both real and check-mode syncs pass --force so that +# path actually refreshes both files from current apm.yml/.apm/ content -- apm pack +# silently skips regenerating an existing plugin.json otherwise ("already exists; +# skipping plugin.json generation"), which would let them go stale after a +# name/version/description edit (real mode) or let --check compare a copy against +# itself and never see the drift (check mode; see sync_plugin_manifest below). # # apm's Copilot-ecosystem plugin.json builder omits mcpServers entirely -- its own # docstring calls it out-of-schema for Copilot, but this repo's researched Copilot # plugin schema docs (plugins/kyberforge/docs/research/docs/github-copilot-plugins/ -# configuration.md) document mcpServers as valid there. Real-mode syncs re-inject it -# into .github/plugin/plugin.json from the plugin's own .mcp.json after apm pack runs -# (see reinject_mcp_servers below); staleness there, like the rest of plugin.json, is -# only fixed by the next real sync, not detected by --check. +# configuration.md) document mcpServers as valid there. Both modes re-inject it into +# .github/plugin/plugin.json from the plugin's own .mcp.json after apm pack runs (see +# reinject_mcp_servers below) -- real mode into the plugin root directly, check mode +# into the throwaway copy first so the manifest diff below sees the same content a +# real sync would actually produce. # # apm pack also writes .claude-plugin/plugin.json and .github/plugin/plugin.json into # cwd whenever those files don't already exist yet -- regardless of --force -- so # --check (which must never mutate the real plugin root) never cds into plugin_dir -# directly. It packs a throwaway copy instead (see sync_one's pack_cwd); only that -# copy's manifest files, never the real ones, can get created as a first-write. +# directly. It packs a throwaway copy instead (see sync_one's pack_cwd), forces +# regeneration of both manifest files inside that copy, then diffs them +# (sync_plugin_manifest) against the real plugin root's committed manifests to catch +# drift in name/version/description/mcpServers -- only the copy's manifest files, +# never the real ones, can get created as a first-write. # -# hooks.json is only synced when .apm/hooks/ actually produces one -- a plugin with -# no .apm/hooks/ content is left alone even if a root-level hooks.json already exists -# (pre-existing scaffolding outside this script's concern). +# hooks.json is mirrored like the other MIRROR_DIRS content: synced when .apm/hooks/ +# produces one, and removed (real mode) / flagged as drift (--check) when it no +# longer does but a root-level hooks.json is still sitting there from a prior sync. # # tests/ subdirectories (e.g. .apm/skills//tests/*.bats) are excluded from the # mirror -- they are dev-time fixtures a plugin host never needs to discover, and several @@ -81,6 +87,12 @@ if ! command -v jq &>/dev/null; then exit 1 fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/marketplace-plugins.sh +source "$SCRIPT_DIR/lib/marketplace-plugins.sh" +# shellcheck source=lib/batch-run.sh +source "$SCRIPT_DIR/lib/batch-run.sh" + # Convention subdirectories apm's plugin exporter can populate from .apm/. MIRROR_DIRS=(agents skills commands instructions extensions) @@ -89,9 +101,11 @@ SCRATCH_ROOT="$(mktemp -d)" trap 'rm -rf "$SCRATCH_ROOT"' EXIT if [[ "$ALL" -eq 1 ]]; then - # Derives the plugin list from marketplace.json the same way - # scripts/check-manifests.sh does, instead of hand-maintaining a duplicate list - # at every call site (see .pre-commit-config.yaml's check-plugin-content-sync). + # Derives the plugin list from marketplace.json via the shared + # list_marketplace_local_plugins helper (scripts/lib/marketplace-plugins.sh), + # the same one scripts/check-manifests.sh uses, instead of hand-maintaining a + # duplicate walk at every call site (see .pre-commit-config.yaml's + # check-plugin-content-sync). REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json" if [[ ! -f "$MARKETPLACE" ]]; then @@ -99,15 +113,9 @@ if [[ "$ALL" -eq 1 ]]; then exit 1 fi declare -a plugin_dirs=() - plugin_count="$(jq '.plugins | length' "$MARKETPLACE")" - for ((i = 0; i < plugin_count; i++)); do - source_type="$(jq -r ".plugins[$i].source | type" "$MARKETPLACE")" - # Remote sources (github, git, npm objects) have no local directory to sync. - [[ "$source_type" == "string" ]] || continue - source="$(jq -r ".plugins[$i].source" "$MARKETPLACE")" - source="${source#./}" - plugin_dirs+=("$REPO_ROOT/$source") - done + while IFS=$'\t' read -r _name plugin_dir; do + plugin_dirs+=("$plugin_dir") + done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE") else declare -a plugin_dirs=("$@") fi @@ -169,27 +177,37 @@ sync_hooks_json() { local plugin_dir="$1" bundle_dir="$2" local src="$bundle_dir/hooks.json" dst="$plugin_dir/hooks.json" - # No .apm/hooks/ content -- hooks.json (if any) is out of scope for this script. - [[ -f "$src" ]] || return 0 - if [[ "$CHECK" -eq 1 ]]; then - local normalized_src - normalized_src="$(mktemp)" - normalize_trailing_newline "$src" "$normalized_src" - if [[ ! -f "$dst" ]] || ! diff -q "$normalized_src" "$dst" >/dev/null 2>&1; then - echo "DRIFT $dst: out of sync with .apm/hooks/" >&2 + if [[ -f "$src" ]]; then + local normalized_src + normalized_src="$(mktemp)" + normalize_trailing_newline "$src" "$normalized_src" + if [[ ! -f "$dst" ]] || ! diff -q "$normalized_src" "$dst" >/dev/null 2>&1; then + echo "DRIFT $dst: out of sync with .apm/hooks/" >&2 + FAIL=1 + fi + rm -f "$normalized_src" + elif [[ -f "$dst" ]]; then + # Mirrors sync_dir()'s orphan handling: .apm/hooks/ no longer produces a + # hooks.json, but one is still sitting at $dst from a prior sync -- that's + # drift (stale mirrored output), not "no .apm/hooks/ content" (which would + # mean $dst never existed in the first place). + echo "DRIFT $dst: stale, no longer produced from .apm/hooks/" >&2 FAIL=1 fi - rm -f "$normalized_src" return 0 fi - normalize_trailing_newline "$src" "$dst" + if [[ -f "$src" ]]; then + normalize_trailing_newline "$src" "$dst" + elif [[ -f "$dst" ]]; then + rm -f "$dst" + fi } reinject_mcp_servers() { - local plugin_dir="$1" - local mcp_src="$plugin_dir/.mcp.json" dst="$plugin_dir/.github/plugin/plugin.json" + local plugin_dir="$1" target_dir="$2" + local mcp_src="$plugin_dir/.mcp.json" dst="$target_dir/.github/plugin/plugin.json" [[ -f "$mcp_src" ]] || return 0 [[ -f "$dst" ]] || return 0 @@ -205,6 +223,29 @@ reinject_mcp_servers() { mv "$tmp" "$dst" } +# --check-only: diffs a freshly-regenerated manifest file (in the throwaway +# pack_cwd copy, produced by a --force'd apm pack) against the one actually +# committed at the real plugin root. Real-mode syncs never need this -- there +# pack_cwd IS plugin_dir, so --force already refreshed the real file in place. +sync_plugin_manifest() { + local plugin_dir="$1" pack_cwd="$2" rel="$3" + local src="$pack_cwd/$rel" dst="$plugin_dir/$rel" + + if [[ -f "$src" ]]; then + if [[ ! -f "$dst" ]]; then + echo "DRIFT $dst: missing (would be created by apm pack from apm.yml/.mcp.json)" >&2 + FAIL=1 + elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then + echo "DRIFT $dst: out of sync with apm.yml/.mcp.json" >&2 + diff "$src" "$dst" 2>&1 | sed 's/^/ /' >&2 + FAIL=1 + fi + elif [[ -f "$dst" ]]; then + echo "DRIFT $dst: stale, no longer produced by apm pack" >&2 + FAIL=1 + fi +} + # Runs entirely inside a backgrounded subshell (see the dispatch loop below), so # FAIL here is that subshell's own copy -- it never touches the parent's FAIL # and must be handed back via status_file instead. @@ -232,9 +273,15 @@ sync_one() { mkdir -p "$scratch" pack_log="$(mktemp)" - local force_flag=() + # --force always: in real mode it refreshes the real plugin.json in place + # (pack_cwd IS plugin_dir there); in check mode it forces regeneration inside + # the throwaway pack_cwd copy so sync_plugin_manifest below has a genuinely + # fresh manifest to diff against the real one -- without --force, apm pack + # would silently skip regenerating a plugin.json that already exists in the + # copy (it was seeded from the real plugin_dir), so --check would always + # compare the copy against itself and never see drift. + local force_flag=(--force) if [[ "$CHECK" -eq 0 ]]; then - force_flag=(--force) pack_cwd="$plugin_dir" else pack_cwd="$SCRATCH_ROOT/$name.checkcopy" @@ -266,7 +313,14 @@ sync_one() { done sync_hooks_json "$plugin_dir" "$bundle_dir" if [[ "$CHECK" -eq 0 ]]; then - reinject_mcp_servers "$plugin_dir" + reinject_mcp_servers "$plugin_dir" "$plugin_dir" + else + # Reinject into the throwaway copy too, so the manifest diff below compares + # against what a real sync would actually produce (mcpServers included), + # not apm's own Copilot-ecosystem output (which omits it). + reinject_mcp_servers "$plugin_dir" "$pack_cwd" + sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".claude-plugin/plugin.json" + sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".github/plugin/plugin.json" fi echo "$FAIL" >"$status_file" } @@ -276,24 +330,16 @@ sync_one() { # than paying that startup cost N times serially. Output is buffered per plugin # (not streamed) so concurrent DRIFT/FAIL messages from different plugins never # interleave; it's flushed in stable $@ order once every job has finished. -# -# Batched (not a rolling pool) because a bounded rolling pool needs `wait -n`, -# which is bash 4.3+ -- tests/run-tests.sh and tests/run-bats.sh in this same repo -# are explicitly bash-3.2-safe, so this script matches their pattern for -# consistency. `getconf` over `nproc` for the same reason: `nproc` doesn't exist -# on macOS. -JOBS_LIMIT="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" -running=0 +# Dispatch/throttling itself is scripts/lib/batch-run.sh's batch_run (shared +# with tests/run-tests.sh and tests/run-bats.sh) -- see that file for why this +# is batched rather than a rolling `wait -n` pool. +declare -a batch_args=() for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do name="$(basename "${plugin_dir%/}")" - (sync_one "$plugin_dir" "$SCRATCH_ROOT/$name.status") >"$SCRATCH_ROOT/$name.log" 2>&1 & - running=$((running + 1)) - if [[ $running -ge $JOBS_LIMIT ]]; then - wait - running=0 - fi + cmd="$(printf 'sync_one %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status")" + batch_args+=("$name" "$cmd") done -wait +batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"} for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do name="$(basename "${plugin_dir%/}")" diff --git a/tests/run-bats.sh b/tests/run-bats.sh index 8aff317..d82c2d2 100755 --- a/tests/run-bats.sh +++ b/tests/run-bats.sh @@ -36,27 +36,24 @@ fi # of these suites) across files, which is where the wall-clock actually goes. # Output is buffered per file so concurrent TAP streams can't interleave, then # flushed in stable sorted order once every job has finished. +# +# Dispatch and throttling is scripts/lib/batch-run.sh's batch_run -- shared +# with scripts/sync-plugin-content.sh and tests/run-tests.sh so a batching bug +# fix only needs to land once; see that file for why this is batched rather +# than a rolling `wait -n` pool. SCRATCH_ROOT="$(mktemp -d)" trap 'rm -rf "$SCRATCH_ROOT"' EXIT +# shellcheck source=../scripts/lib/batch-run.sh +source "$REPO_ROOT/scripts/lib/batch-run.sh" -# Batches (not a rolling pool) because a bounded rolling pool needs `wait -n`, -# which is bash 4.3+ and this script is explicitly bash-3.2-safe like -# run-tests.sh; plain `wait` -- waiting for every job in the current batch -- -# is available since ancient bash. `getconf` over `nproc` for the same -# reason: `nproc` doesn't exist on macOS. -JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" -running=0 +declare -a batch_args=() i=0 for f in "${TEST_FILES[@]}"; do i=$((i + 1)) - ( "$BATS" "$f" >"$SCRATCH_ROOT/$i.log" 2>&1; echo $? >"$SCRATCH_ROOT/$i.status" ) & - running=$((running + 1)) - if [[ "$running" -ge "$JOBS" ]]; then - wait - running=0 - fi + cmd="$(printf '%q %q; echo $? >%q' "$BATS" "$f" "$SCRATCH_ROOT/$i.status")" + batch_args+=("$i" "$cmd") done -wait +batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"} FAIL=0 TOTAL_OK=0 diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 26de1d4..ba5b521 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -51,32 +51,24 @@ done < <( # Each test-*.sh is independent (fixtures live under its own mktemp dir, none # write back into the live repo tree -- verified before adding this), so they -# run concurrently in fixed-size batches instead of one at a time. Batches -# (not a rolling pool) because a bounded rolling pool needs `wait -n`, which -# is bash 4.3+ and this script is explicitly bash-3.2-safe (see the hazard -# scan in test-vale-wrap.sh); plain `wait` -- waiting for every job in the -# current batch -- is available since ancient bash. `getconf` over `nproc` -# for the same reason: `nproc` doesn't exist on macOS. +# run concurrently in fixed-size batches instead of one at a time. Dispatch and +# throttling is scripts/lib/batch-run.sh's batch_run -- shared with +# scripts/sync-plugin-content.sh and tests/run-bats.sh so a batching bug fix +# only needs to land once; see that file for why this is batched rather than a +# rolling `wait -n` pool. SCRATCH_ROOT="$(mktemp -d)" trap 'rm -rf "$SCRATCH_ROOT"' EXIT -JOBS_LIMIT="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" +# shellcheck source=lib/batch-run.sh +source "$REPO_ROOT/scripts/lib/batch-run.sh" -running=0 +declare -a batch_args=() idx=0 for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do idx=$((idx + 1)) - ( - rc=0 - bash "$script" >"$SCRATCH_ROOT/$idx.log" 2>&1 || rc=$? - echo "$rc" >"$SCRATCH_ROOT/$idx.status" - ) & - running=$((running + 1)) - if [[ $running -ge $JOBS_LIMIT ]]; then - wait - running=0 - fi + cmd="$(printf 'rc=0; bash %q || rc=$?; echo "$rc" >%q' "$script" "$SCRATCH_ROOT/$idx.status")" + batch_args+=("$idx" "$cmd") done -wait +batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"} idx=0 for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do diff --git a/tests/test-sync-plugin-content.sh b/tests/test-sync-plugin-content.sh index 650426c..44080e0 100755 --- a/tests/test-sync-plugin-content.sh +++ b/tests/test-sync-plugin-content.sh @@ -236,6 +236,73 @@ 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.json after .apm/hooks/ is removed --- +echo "" +echo "--- --check detects an orphaned hooks.json when .apm/hooks/ is removed ---" +FIXTURE13="$(make_fixture)"; track "$FIXTURE13" +bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1 +if [[ ! -f "$FIXTURE13/hooks.json" ]]; then + fail "initial sync did not create 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.json after .apm/hooks/ removal" +else + pass "orphaned hooks.json is detected as drift" + bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1 + if [[ ! -e "$FIXTURE13/hooks.json" ]]; then + pass "re-sync removes the orphaned hooks.json" + else + fail "re-sync left the orphaned hooks.json in place" + fi + if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then + pass "re-sync clears the orphaned-hooks.json drift" + else + fail "re-sync did not clear the orphaned-hooks.json drift" + fi +fi + echo "" echo "Results: $PASS passed, $FAIL failed" [[ $FAIL -eq 0 ]]