Compare commits
6 Commits
911daddbe2
...
e79497b3cf
| Author | SHA1 | Date | |
|---|---|---|---|
| e79497b3cf | |||
| 23cef3627a | |||
| fd70c8d65e | |||
| 07ea0aeb17 | |||
| 925f04acdb | |||
| c6490096da |
@@ -69,7 +69,7 @@ repos:
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
files: '^(scripts/sync-plugin-content\.sh|\.claude-plugin/marketplace\.json|plugins/[^/]+/(apm\.yml|\.mcp\.json|hooks\.json|\.apm/|agents/|skills/|commands/|instructions/|extensions/))'
|
||||
|
||||
- id: check-marketplace-mirror-sync
|
||||
name: Check marketplace mirror sync
|
||||
|
||||
@@ -87,14 +87,14 @@ for fpath in find_agents_md(repo_root):
|
||||
with open(fpath, encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if PLACEHOLDER_RE.search(line):
|
||||
continue
|
||||
for label, pattern in PATTERNS:
|
||||
m = pattern.search(line)
|
||||
if not m:
|
||||
continue
|
||||
# Re-check placeholder allowlist against just the matched value, in case
|
||||
# the placeholder marker sits outside the regex's own match span.
|
||||
# Scope the placeholder allowlist to the matched secret-candidate
|
||||
# substring only. Checking the whole line would let an unrelated
|
||||
# placeholder-looking token elsewhere on the line (e.g. in a
|
||||
# trailing comment) suppress detection of a real credential.
|
||||
value = m.group(0)
|
||||
if PLACEHOLDER_RE.search(value):
|
||||
continue
|
||||
|
||||
@@ -52,6 +52,19 @@ EOF
|
||||
assert_output --partial "connection string"
|
||||
}
|
||||
|
||||
@test "still catches a real secret when a placeholder token sits elsewhere on the same line" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
- AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP # see your-token-here for an example, gitleaks:allow (synthetic fixture — this test verifies the placeholder allowlist is scoped to the matched value, not the whole line)
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "AWS access key ID"
|
||||
assert_output --partial "AGENTS.md:4"
|
||||
}
|
||||
|
||||
@test "detects secrets in a nested AGENTS.md, not just root" {
|
||||
mkdir -p "$TMPDIR/packages/api"
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"hooks": {}
|
||||
}
|
||||
@@ -87,14 +87,14 @@ for fpath in find_agents_md(repo_root):
|
||||
with open(fpath, encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if PLACEHOLDER_RE.search(line):
|
||||
continue
|
||||
for label, pattern in PATTERNS:
|
||||
m = pattern.search(line)
|
||||
if not m:
|
||||
continue
|
||||
# Re-check placeholder allowlist against just the matched value, in case
|
||||
# the placeholder marker sits outside the regex's own match span.
|
||||
# Scope the placeholder allowlist to the matched secret-candidate
|
||||
# substring only. Checking the whole line would let an unrelated
|
||||
# placeholder-looking token elsewhere on the line (e.g. in a
|
||||
# trailing comment) suppress detection of a real credential.
|
||||
value = m.group(0)
|
||||
if PLACEHOLDER_RE.search(value):
|
||||
continue
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"hooks": {}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"hooks": {}
|
||||
}
|
||||
@@ -7,9 +7,18 @@ set -euo pipefail
|
||||
# Per ADR-0015, apm.yml is the authoring source and .claude-plugin/plugin.json is
|
||||
# compiled output with no skills/hooks/mcpServers/agents pointer fields (apm's plugin.json
|
||||
# builder deliberately omits them -- Claude Code auto-discovers those convention
|
||||
# directories, so listing them would be redundant/invalid). This script no longer checks
|
||||
# those fields; that's now scripts/sync-plugin-content.sh --check's job (drift between
|
||||
# .apm/ and the flat plugin-root mirror), wired as its own pre-push hook.
|
||||
# directories, so listing them would be redundant/invalid). For a plugin with an .apm/
|
||||
# directory, this script no longer checks those pointer fields itself; that's
|
||||
# scripts/sync-plugin-content.sh --check's job (drift between .apm/ and the flat
|
||||
# plugin-root mirror), wired as its own pre-push hook.
|
||||
#
|
||||
# sync-plugin-content.sh --check explicitly skips any plugin directory lacking .apm/
|
||||
# (an apm-native package it has nothing to compile), so that delegation leaves a real
|
||||
# gap for a non-apm plugin whose hand-authored plugin.json still uses the old
|
||||
# skills/hooks/mcpServers/agents pointer-field convention: nothing would check whether
|
||||
# those paths resolve. The fallback block below restores that check, but only for
|
||||
# plugins without .apm/ -- apm-native plugins keep relying on the delegation above so
|
||||
# the two checks don't duplicate (and disagree) on the same manifest.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
FAIL=0
|
||||
@@ -26,32 +35,50 @@ if [[ ! -f "$MARKETPLACE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
plugin_count=$(jq '.plugins | length' "$MARKETPLACE")
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/marketplace-plugins.sh
|
||||
source "$SCRIPT_DIR/lib/marketplace-plugins.sh"
|
||||
|
||||
for ((i = 0; i < plugin_count; i++)); do
|
||||
name=$(jq -r ".plugins[$i].name" "$MARKETPLACE")
|
||||
source_type=$(jq -r ".plugins[$i].source | type" "$MARKETPLACE")
|
||||
|
||||
# Remote sources (github, git, npm objects) have no local directory to check
|
||||
if [[ "$source_type" != "string" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
source=$(jq -r ".plugins[$i].source" "$MARKETPLACE")
|
||||
source="${source#./}"
|
||||
plugin_dir="$REPO_ROOT/$source"
|
||||
while IFS=$'\t' read -r name plugin_dir; do
|
||||
source_rel="${plugin_dir#"$REPO_ROOT"/}"
|
||||
|
||||
if [[ ! -d "$plugin_dir" ]]; then
|
||||
err "plugin '$name': source directory not found: $source"
|
||||
err "plugin '$name': source directory not found: $source_rel"
|
||||
continue
|
||||
fi
|
||||
|
||||
manifest="$plugin_dir/.claude-plugin/plugin.json"
|
||||
if [[ ! -f "$manifest" ]]; then
|
||||
err "plugin '$name': .claude-plugin/plugin.json not found in $source"
|
||||
err "plugin '$name': .claude-plugin/plugin.json not found in $source_rel"
|
||||
continue
|
||||
fi
|
||||
done
|
||||
|
||||
# apm-native plugin: pointer-field validation is sync-plugin-content.sh --check's
|
||||
# job (see header comment above).
|
||||
[[ -d "$plugin_dir/.apm" ]] && continue
|
||||
|
||||
# Fallback for a non-apm plugin: validate that any skills/hooks/mcpServers/agents
|
||||
# pointer fields in its hand-authored plugin.json still resolve to real paths.
|
||||
skill_count=$(jq '.skills | if . then length else 0 end' "$manifest")
|
||||
for ((s = 0; s < skill_count; s++)); do
|
||||
skill_path=$(jq -r ".skills[$s]" "$manifest")
|
||||
full_path="$plugin_dir/$skill_path"
|
||||
full_path="${full_path%/}"
|
||||
if [[ ! -d "$full_path" ]]; then
|
||||
err "plugin '$name': skills path not found: $skill_path"
|
||||
fi
|
||||
done
|
||||
|
||||
for field in hooks mcpServers agents; do
|
||||
ref=$(jq -r ".${field} // empty" "$manifest")
|
||||
[[ -z "$ref" ]] && continue
|
||||
full_path="$plugin_dir/$ref"
|
||||
full_path="${full_path%/}"
|
||||
if [[ ! -e "$full_path" ]]; then
|
||||
err "plugin '$name': $field path not found: $ref"
|
||||
fi
|
||||
done
|
||||
done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo "Manifest check failed: $FAIL error(s)" >&2
|
||||
|
||||
@@ -83,33 +83,43 @@ for ini in "$SKILL_INI" "$AGENT_INI"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Prints the `files:` regex of every hook, in either manifest, whose entry is
|
||||
# $1's vale-wrap.sh. Records are delimited by their `- id:` line, so the check
|
||||
# does not depend on `entry:` preceding `files:` within a record.
|
||||
# Prints the `files:` regex of every hook, in ONE manifest ($2), whose entry
|
||||
# is $1's vale-wrap.sh. Records are delimited by their `- id:` line, so the
|
||||
# check does not depend on `entry:` preceding `files:` within a record.
|
||||
#
|
||||
# Cached per skill (parallel HOOK_REGEX_CACHE_KEYS/_VALS arrays, populated
|
||||
# lazily) because the final validation loop below probes agent-audit twice —
|
||||
# once for its CC agent-file shape, once for its Copilot .agent.md shape — and
|
||||
# both probes need the same regex set. Without the cache, that pair of calls
|
||||
# would each re-parse both manifest files from scratch for no new information.
|
||||
# Plain indexed arrays, not `declare -A`: associative arrays are bash 4.0+ and
|
||||
# this script must run on macOS's stock bash 3.2. Only ${#arr[@]} (always safe
|
||||
# on an empty/unset array under `set -u`) and index access are used below —
|
||||
# never a bare `${arr[@]}` expansion, which aborts on bash < 4.4 under nounset.
|
||||
# Deliberately kept per-manifest rather than unioned across both files: the
|
||||
# validation loop below needs to know whether a probe path is in scope of
|
||||
# .pre-commit-hooks.yaml (the canonical, external-facing manifest) and
|
||||
# .pre-commit-config.yaml (this repo's own dev-time copy of the same hook)
|
||||
# *independently*. A union here previously let a probe that matched only the
|
||||
# older, looser .pre-commit-hooks.yaml pattern read as "in scope" even after
|
||||
# .pre-commit-config.yaml's copy of the same hook had been narrowed away from
|
||||
# it — silently masking exactly the kind of hook-rescoping drift this script
|
||||
# exists to catch.
|
||||
#
|
||||
# Cached per (skill, manifest) pair (parallel HOOK_REGEX_CACHE_KEYS/_VALS
|
||||
# arrays, populated lazily) because the final validation loop below probes
|
||||
# agent-audit's two manifests across three probe shapes; without the cache,
|
||||
# each repeated (skill, manifest) pairing would re-parse the same manifest
|
||||
# file from scratch for no new information. Plain indexed arrays, not
|
||||
# `declare -A`: associative arrays are bash 4.0+ and this script must run on
|
||||
# macOS's stock bash 3.2. Only ${#arr[@]} (always safe on an empty/unset array
|
||||
# under `set -u`) and index access are used below — never a bare `${arr[@]}`
|
||||
# expansion, which aborts on bash < 4.4 under nounset.
|
||||
HOOK_REGEX_CACHE_KEYS=()
|
||||
HOOK_REGEX_CACHE_VALS=()
|
||||
hook_file_regexes() {
|
||||
local skill="$1" manifest raw result idx=0
|
||||
local skill="$1" manifest="$2" raw result idx=0
|
||||
local cache_key="$skill|$manifest"
|
||||
while [[ $idx -lt ${#HOOK_REGEX_CACHE_KEYS[@]} ]]; do
|
||||
if [[ "${HOOK_REGEX_CACHE_KEYS[$idx]}" == "$skill" ]]; then
|
||||
if [[ "${HOOK_REGEX_CACHE_KEYS[$idx]}" == "$cache_key" ]]; then
|
||||
printf '%s' "${HOOK_REGEX_CACHE_VALS[$idx]}"
|
||||
return
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
result="$(
|
||||
for manifest in "$REPO_ROOT/.pre-commit-hooks.yaml" "$REPO_ROOT/.pre-commit-config.yaml"; do
|
||||
[[ -f "$manifest" ]] || continue
|
||||
if [[ -f "$manifest" ]]; then
|
||||
awk -v skill="$skill" '
|
||||
function flush() {
|
||||
if (entry ~ skill "/scripts/vale-wrap.sh" && files != "") print files
|
||||
@@ -119,19 +129,34 @@ hook_file_regexes() {
|
||||
/^[ \t]*entry:/ { entry = $0 }
|
||||
/^[ \t]*files:/ { files = $0; sub(/^[ \t]*files:[ \t]*/, "", files) }
|
||||
END { flush() }
|
||||
' "$manifest"
|
||||
done | while IFS= read -r raw; do
|
||||
' "$manifest" | while IFS= read -r raw; do
|
||||
# Strip the surrounding YAML quotes; the regex itself never carries them.
|
||||
raw="${raw%\'}"; raw="${raw#\'}"
|
||||
raw="${raw%\"}"; raw="${raw#\"}"
|
||||
printf '%s\n' "$raw"
|
||||
done
|
||||
fi
|
||||
)"
|
||||
HOOK_REGEX_CACHE_KEYS[${#HOOK_REGEX_CACHE_KEYS[@]}]="$skill"
|
||||
HOOK_REGEX_CACHE_KEYS[${#HOOK_REGEX_CACHE_KEYS[@]}]="$cache_key"
|
||||
HOOK_REGEX_CACHE_VALS[${#HOOK_REGEX_CACHE_VALS[@]}]="$result"
|
||||
printf '%s' "$result"
|
||||
}
|
||||
|
||||
# True if $1 matches at least one newline-delimited regex in $2.
|
||||
matches_any_regex() {
|
||||
local rel="$1" regexes="$2" re
|
||||
[[ -n "$regexes" ]] || return 1
|
||||
while IFS= read -r re; do
|
||||
[[ -n "$re" ]] || continue
|
||||
if printf '%s\n' "$rel" | grep -Eq "$re"; then
|
||||
return 0
|
||||
fi
|
||||
done <<EOF_RE
|
||||
$regexes
|
||||
EOF_RE
|
||||
return 1
|
||||
}
|
||||
|
||||
# Asks vale — the thing that actually applies these globs — whether a config
|
||||
# covers a path, rather than reimplementing doublestar matching. The probe file
|
||||
# carries a description with a token Kyberforge.VagueWording flags, so a config
|
||||
@@ -160,39 +185,53 @@ if ! command -v vale >/dev/null 2>&1; then
|
||||
echo " WARNING: vale is not installed — .vale.ini glob coverage was NOT verified. Install it (https://vale.sh/docs/vale-cli/installation/) before trusting a clean run." >&2
|
||||
fi
|
||||
|
||||
# One representative path per file shape the prefilter is supposed to cover. Each
|
||||
# is cross-checked against the shipped hooks' `files:` regexes first, so a path
|
||||
# that goes stale because a hook was rescoped fails loudly here instead of
|
||||
# quietly probing a shape nothing lints any more.
|
||||
while IFS='|' read -r skill rel; do
|
||||
# One representative path per file shape the prefilter is supposed to cover,
|
||||
# tagged with whether that shape is expected to be in scope of BOTH manifests
|
||||
# ("shared") or only the external-facing .pre-commit-hooks.yaml ("hooks-only"
|
||||
# — e.g. a Copilot .agent.md file living outside this repo's own plugins/.apm/
|
||||
# layout, which .pre-commit-config.yaml's repo-scoped regex has no reason to
|
||||
# cover). Each probe is checked against the two manifests' `files:` regexes
|
||||
# *separately*, not unioned: a path that goes stale because a hook was
|
||||
# rescoped fails loudly here instead of quietly probing a shape nothing lints
|
||||
# any more, and a "shared" path the two manifests disagree on fails loudly
|
||||
# too — that disagreement is exactly how .pre-commit-config.yaml's regex can
|
||||
# narrow out of sync with .pre-commit-hooks.yaml's without either manifest's
|
||||
# own hook breaking (each still matches real files on its own), so nothing
|
||||
# else would catch it.
|
||||
while IFS='|' read -r skill rel scope; do
|
||||
[[ -n "$skill" ]] || continue
|
||||
dir="$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill"
|
||||
ini="$dir/assets/vale/.vale.ini"
|
||||
[[ -f "$ini" ]] || continue
|
||||
|
||||
regexes="$(hook_file_regexes "$skill")"
|
||||
if [[ -n "$regexes" ]]; then
|
||||
in_scope=false
|
||||
while IFS= read -r re; do
|
||||
[[ -n "$re" ]] || continue
|
||||
if printf '%s\n' "$rel" | grep -Eq "$re"; then
|
||||
in_scope=true
|
||||
fi
|
||||
done <<EOF_RE
|
||||
$regexes
|
||||
EOF_RE
|
||||
if [[ "$in_scope" == false ]]; then
|
||||
hooks_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-hooks.yaml")"
|
||||
config_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-config.yaml")"
|
||||
in_hooks=false
|
||||
matches_any_regex "$rel" "$hooks_regexes" && in_hooks=true
|
||||
in_config=false
|
||||
matches_any_regex "$rel" "$config_regexes" && in_config=true
|
||||
|
||||
if [[ "$in_hooks" == false && "$in_config" == false ]]; then
|
||||
err "$rel matches no 'files:' regex of any $skill hook — the probe path is stale, or the hook was rescoped away from a shape it still needs to lint"
|
||||
fi
|
||||
elif [[ "$scope" == "shared" && "$in_hooks" != "$in_config" ]]; then
|
||||
err "$rel is in scope of $skill's hook in .pre-commit-hooks.yaml but not .pre-commit-config.yaml (or vice versa: hooks=$in_hooks, config=$in_config) — the local and canonical 'files:' regexes have drifted out of sync for this hook"
|
||||
fi
|
||||
|
||||
if [[ "$VALE_AVAILABLE" == true ]] && ! vale_flags_path "$ini" "$rel"; then
|
||||
err "$skill/assets/vale/.vale.ini raises no Kyberforge alert on $rel — its glob sections do not cover a path its own pre-commit hook is scoped to, so the hook passes that shape without linting it"
|
||||
fi
|
||||
# `demo.md` (bare, no `.agent.md` suffix) is `hooks-only` rather than
|
||||
# `shared`: it exists only to exercise agent-audit's `[**/agents/*.md]` glob
|
||||
# section in isolation from `[**/*.agent.md]` (test-check-vale-style-sync.sh's
|
||||
# case 10), not because any real file under `.apm/agents/` still has that
|
||||
# shape — per ADR-0016 every `.apm/agents/*` file is named `*.agent.md`, so
|
||||
# `.pre-commit-config.yaml`'s regex correctly no longer matches it and that's
|
||||
# not drift. `demo.agent.md` is the real, current shape and is `shared`.
|
||||
done <<'EOF_PROBE'
|
||||
skill-audit|plugins/demo/.apm/skills/demo/SKILL.md
|
||||
agent-audit|plugins/demo/.apm/agents/demo.md
|
||||
agent-audit|copilot/demo.agent.md
|
||||
skill-audit|plugins/demo/.apm/skills/demo/SKILL.md|shared
|
||||
agent-audit|plugins/demo/.apm/agents/demo.md|hooks-only
|
||||
agent-audit|plugins/demo/.apm/agents/demo.agent.md|shared
|
||||
agent-audit|copilot/demo.agent.md|hooks-only
|
||||
EOF_PROBE
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
|
||||
53
scripts/lib/batch-run.sh
Normal file
53
scripts/lib/batch-run.sh
Normal file
@@ -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 <scratch_dir> <key1> <cmd1> [<key2> <cmd2> ...]
|
||||
#
|
||||
# For each key/cmd pair, backgrounds `eval "$cmd"` with its combined
|
||||
# stdout+stderr redirected to "<scratch_dir>/<key>.log", bounded to at most
|
||||
# batch_jobs_limit concurrent jobs (waiting out the current batch before
|
||||
# starting the next).
|
||||
#
|
||||
# Each <cmd> owns writing its own result to "<scratch_dir>/<key>.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
|
||||
}
|
||||
28
scripts/lib/marketplace-plugins.sh
Normal file
28
scripts/lib/marketplace-plugins.sh
Normal file
@@ -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 <repo_root> <marketplace_json_path>
|
||||
#
|
||||
# Prints one "<name>\t<absolute_plugin_dir>" 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
|
||||
}
|
||||
@@ -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/<name>/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,10 +177,8 @@ 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
|
||||
if [[ -f "$src" ]]; then
|
||||
local normalized_src
|
||||
normalized_src="$(mktemp)"
|
||||
normalize_trailing_newline "$src" "$normalized_src"
|
||||
@@ -181,15 +187,27 @@ sync_hooks_json() {
|
||||
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
|
||||
return 0
|
||||
fi
|
||||
|
||||
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%/}")"
|
||||
|
||||
@@ -16,7 +16,15 @@ if [[ ! -x "$BATS" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t TEST_FILES < <(
|
||||
# Collected with a `while read` loop rather than `mapfile` — macOS ships
|
||||
# /bin/bash 3.2, which has no `mapfile`. Process substitution (not a pipe)
|
||||
# keeps the loop in this shell so the appends survive. `sort` is still fed
|
||||
# newline-delimited output, exactly as before. Same convention as
|
||||
# tests/run-tests.sh.
|
||||
TEST_FILES=()
|
||||
while IFS= read -r f; do
|
||||
TEST_FILES+=("$f")
|
||||
done < <(
|
||||
find "$REPO_ROOT" -name "*.bats" \
|
||||
-not -path "*/tests/bats/*" \
|
||||
-not -path "*/test_helper/*" \
|
||||
@@ -36,33 +44,30 @@ 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
|
||||
for f in ${TEST_FILES[@]+"${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
|
||||
TOTAL_NOT_OK=0
|
||||
i=0
|
||||
for f in "${TEST_FILES[@]}"; do
|
||||
for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
i=$((i + 1))
|
||||
rel="${f#"$REPO_ROOT"/}"
|
||||
echo "=== $rel ==="
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,97 @@ else
|
||||
pass "exits non-zero for a broken local entry even alongside a skipped remote entry"
|
||||
fi
|
||||
|
||||
# --- 5. Non-.apm/ plugin with a broken pointer field is caught by the fallback path ---
|
||||
# apm-native plugins (.apm/ present) get their skills/hooks/mcpServers/agents
|
||||
# pointer-field validation from sync-plugin-content.sh --check instead (see this
|
||||
# script's header comment) -- but that script skips any plugin dir lacking .apm/
|
||||
# outright, so a non-apm plugin's hand-authored plugin.json needs this script's own
|
||||
# fallback validation to catch a broken pointer field.
|
||||
echo ""
|
||||
echo "--- catches a broken pointer field in a non-apm plugin's plugin.json ---"
|
||||
FIXTURE5="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE5"' EXIT
|
||||
mkdir -p "$FIXTURE5/.claude-plugin"
|
||||
mkdir -p "$FIXTURE5/plugins/legacy/.claude-plugin"
|
||||
cat > "$FIXTURE5/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "legacy", "source": "./plugins/legacy" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
cat > "$FIXTURE5/plugins/legacy/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "legacy",
|
||||
"skills": ["./skills/does-not-exist"]
|
||||
}
|
||||
JSON
|
||||
if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then
|
||||
fail "exited 0 for a non-apm plugin with a broken skills pointer -- expected exit 1"
|
||||
else
|
||||
pass "catches a broken skills pointer field in a non-apm (no .apm/) plugin.json"
|
||||
fi
|
||||
|
||||
# --- 6. Non-.apm/ plugin with valid pointer fields still passes (no false positive) ---
|
||||
echo ""
|
||||
echo "--- a non-apm plugin with valid pointer fields still passes ---"
|
||||
FIXTURE6="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE6"' EXIT
|
||||
mkdir -p "$FIXTURE6/.claude-plugin"
|
||||
mkdir -p "$FIXTURE6/plugins/legacy-ok/.claude-plugin"
|
||||
mkdir -p "$FIXTURE6/plugins/legacy-ok/skills/real-skill"
|
||||
cat > "$FIXTURE6/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "legacy-ok", "source": "./plugins/legacy-ok" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
cat > "$FIXTURE6/plugins/legacy-ok/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "legacy-ok",
|
||||
"skills": ["./skills/real-skill"]
|
||||
}
|
||||
JSON
|
||||
if bash "$SCRIPT" "$FIXTURE6" > /dev/null 2>&1; then
|
||||
pass "a non-apm plugin with a resolving skills pointer passes"
|
||||
else
|
||||
fail "exited non-zero for a non-apm plugin whose pointer fields all resolve"
|
||||
fi
|
||||
|
||||
# --- 7. An .apm/ plugin with a broken pointer field is NOT caught here (delegated) ---
|
||||
# Guards against the fallback path in finding #6 accidentally widening to also
|
||||
# validate apm-native plugins, which would duplicate (and could disagree with)
|
||||
# sync-plugin-content.sh --check's own drift detection.
|
||||
echo ""
|
||||
echo "--- an apm-native plugin's pointer fields are left to sync-plugin-content.sh --check ---"
|
||||
FIXTURE7="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE7"' EXIT
|
||||
mkdir -p "$FIXTURE7/.claude-plugin"
|
||||
mkdir -p "$FIXTURE7/plugins/apm-plugin/.claude-plugin"
|
||||
mkdir -p "$FIXTURE7/plugins/apm-plugin/.apm"
|
||||
cat > "$FIXTURE7/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "apm-plugin", "source": "./plugins/apm-plugin" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
cat > "$FIXTURE7/plugins/apm-plugin/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "apm-plugin",
|
||||
"skills": ["./skills/does-not-exist"]
|
||||
}
|
||||
JSON
|
||||
if bash "$SCRIPT" "$FIXTURE7" > /dev/null 2>&1; then
|
||||
pass "an apm-native plugin (has .apm/) is not checked here, even with a broken pointer field"
|
||||
else
|
||||
fail "check-manifests.sh failed on an apm-native plugin -- pointer-field validation should be delegated, not duplicated"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -95,7 +95,7 @@ run_check() {
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT
|
||||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||||
track() { CLEANUP_DIRS+=("$1"); }
|
||||
|
||||
# --- 1. Not targeting main: silent no-op regardless of state ---
|
||||
|
||||
@@ -258,6 +258,31 @@ else
|
||||
pass "exits non-zero when a probe path is in no hook's scope any more"
|
||||
fi
|
||||
|
||||
# --- 11b. Exits 1 when the local config's files: regex narrows out of sync
|
||||
# with the canonical .pre-commit-hooks.yaml regex ---
|
||||
# hook_file_regexes() used to union the two manifests' `files:` regexes before
|
||||
# checking probe coverage, so a probe that matched only the old, looser
|
||||
# .pre-commit-hooks.yaml pattern still passed as "in scope" even after
|
||||
# .pre-commit-config.yaml's copy of the same hook was narrowed away from it.
|
||||
# That is exactly the shape of rescoping this repo's own agent hook went
|
||||
# through (SKILL/agent `.md` -> `.apm/.../*.agent.md`): the local hook quietly
|
||||
# stopped linting a shape the shipped, external-facing manifest still claims
|
||||
# to cover, and nothing caught it. Reproduce it directly: narrow only the
|
||||
# fixture's local config regex (leave .pre-commit-hooks.yaml as shipped) and
|
||||
# assert the check now flags the disagreement instead of passing silently.
|
||||
echo ""
|
||||
echo "--- exits 1 when .pre-commit-config.yaml's files: regex drifts out of sync with .pre-commit-hooks.yaml's ---"
|
||||
FIXTURE16B="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE16B")
|
||||
break_glob "$FIXTURE16B/.pre-commit-config.yaml" \
|
||||
"files: '^plugins/[^/]+/\\.apm/agents/[^/]+\\.agent\\.md\$'" \
|
||||
"files: '^plugins/kyberforge/\\.apm/agents/[^/]+\\.agent\\.md\$'"
|
||||
if bash "$SCRIPT" "$FIXTURE16B" > /dev/null 2>&1; then
|
||||
fail "exited 0 when the local config regex narrowed out of sync with .pre-commit-hooks.yaml — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when the local config regex narrows out of sync with the canonical .pre-commit-hooks.yaml regex"
|
||||
fi
|
||||
|
||||
# --- 12. The text-level assertions hold on a machine without vale ---
|
||||
# They are the fallback when the glob probe cannot run. With vale on PATH the
|
||||
# probe fails on these same mutations, so it would mask them: only masking vale
|
||||
|
||||
@@ -44,7 +44,7 @@ run_script() {
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT
|
||||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||||
track() { CLEANUP_DIRS+=("$1"); }
|
||||
|
||||
# --- 1. No .claude-plugin/marketplace.json at all: real-sync mode is a no-op, exit 0 ---
|
||||
|
||||
@@ -104,7 +104,7 @@ EOF
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT
|
||||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||||
track() { CLEANUP_DIRS+=("$1"); }
|
||||
|
||||
# --- 1. --check reports drift before any sync has run ---
|
||||
@@ -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 ]]
|
||||
|
||||
@@ -444,7 +444,12 @@ fi
|
||||
# site, so the construct is not a hazard there and demanding the guarded form
|
||||
# would be a wrong test. The file list covers every script this repo ships or
|
||||
# runs that a macOS user reaches: the wrapper itself, the two pre-commit hook
|
||||
# scripts, and the test runner AGENTS.md tells contributors to run by hand.
|
||||
# scripts, the test runner AGENTS.md tells contributors to run by hand, its
|
||||
# bats-dispatch companion, and the three test-*.sh scripts whose
|
||||
# `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` cleanup traps were unguarded (PR
|
||||
# #95 review finding #7 named two of them; a repo-wide grep for the same
|
||||
# pattern turned up test-check-release-needed.sh as a third) until they were
|
||||
# switched to the guarded form.
|
||||
# `mapfile` is checked alongside, because it is bash 4.0+ and the expansion scan
|
||||
# cannot see it — run-tests.sh carried one until it was replaced with a
|
||||
# `while read` loop, and nothing would have caught its return. `declare -A`
|
||||
@@ -478,7 +483,11 @@ for BASH32_SCRIPT in \
|
||||
"$REPO_ROOT/scripts/skill-size-check.sh" \
|
||||
"$REPO_ROOT/scripts/check-release-needed.sh" \
|
||||
"$REPO_ROOT/scripts/check-vale-style-sync.sh" \
|
||||
"$REPO_ROOT/tests/run-tests.sh"; do
|
||||
"$REPO_ROOT/tests/run-tests.sh" \
|
||||
"$REPO_ROOT/tests/run-bats.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-marketplace-mirror.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-plugin-content.sh" \
|
||||
"$REPO_ROOT/tests/test-check-release-needed.sh"; do
|
||||
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
|
||||
if [[ -n "$FOUND16" ]]; then
|
||||
HAZARDS16+="$FOUND16 "
|
||||
|
||||
Reference in New Issue
Block a user