6 Commits

Author SHA1 Message Date
e79497b3cf fix(tests): guard remaining bash 3.2 hazards from PR #95 review
Review findings #5 and #7 on PR #95 flagged two bash-3.2-incompatible
patterns despite the surrounding scripts claiming 3.2 safety:

- tests/run-bats.sh used `mapfile` (bash 4.0+), which fails immediately
  under macOS's stock bash 3.2 before any batching logic runs. Replaced
  with the `while read` loop already established in tests/run-tests.sh,
  and guarded the two downstream `${TEST_FILES[@]}` expansions with
  `${arr[@]+"${arr[@]}"}` to match that file's convention.

- `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` was unguarded in
  tests/test-sync-marketplace-mirror.sh and
  tests/test-sync-plugin-content.sh: under `set -u`, if `mktemp -d`
  fails before the array is populated, the trap itself throws an
  unbound-variable error that masks the real test failure. A repo-wide
  grep for the same pattern turned up a third, unreviewed instance in
  tests/test-check-release-needed.sh. Fixed all three with the guarded
  idiom already used elsewhere in the repo.

Extended the existing bash-3.2-hazard static check (test 16 in
tests/test-vale-wrap.sh) to scan all four fixed files going forward,
so a regression of either pattern fails the suite instead of only
surfacing on a real bash 3.2 host.

Refs: PR #95
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 22:23:45 +00:00
23cef3627a fix(kyberforge): restore pointer-field validation for non-apm plugins
Skills/hooks/mcpServers/agents pointer-field validation in plugin.json was
fully delegated to sync-plugin-content.sh --check, but that script explicitly
skips any plugin directory lacking .apm/ (it has nothing to compile there).
A plugin with no .apm/ and a hand-authored plugin.json whose pointer field
points at a missing path was therefore left uncovered by either check --
currently latent since every plugin in this repo has .apm/, but a real gap
for the first non-apm plugin added.

Restores a fallback validation path here for exactly that case (no .apm/
directory), reusing the pre-delegation logic this script used to run
unconditionally. apm-native plugins keep relying on the delegated check so
the two never duplicate (or disagree) on the same manifest.

Also switches the marketplace.json walk to the shared
scripts/lib/marketplace-plugins.sh helper introduced alongside
sync-plugin-content.sh's matching --all branch, replacing the
near-identical hand-duplicated loop this script's own header comment
already flagged as a duplication risk.

Adds fixtures: a non-apm plugin with a broken skills pointer (caught), a
non-apm plugin with a valid pointer (no false positive), and an apm-native
plugin with a broken pointer (left to the delegated check, not
double-validated here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 22:12:15 +00:00
fd70c8d65e 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 22:12:04 +00:00
07ea0aeb17 fix(kyberforge): stop union-masking drift between vale-audit-prefilter manifests
hook_file_regexes() unioned the `files:` regex from .pre-commit-hooks.yaml
and .pre-commit-config.yaml before checking whether a probe path is in
scope of a kyberforge vale-audit-prefilter hook. That union let a probe
matching only the old, looser .pre-commit-hooks.yaml pattern pass even
after .pre-commit-config.yaml's copy of the same hook had been narrowed
(e.g. to require a `.agent.md` suffix) -- silently masking exactly the
kind of hook-rescoping drift this check exists to catch. Per ADR-0014
the two manifests are meant to exercise the same resolution path an
external consumer's hook would, so this divergence is real drift, not
noise.

hook_file_regexes() now takes the manifest path explicitly and caches
per (skill, manifest) pair instead of per skill, so each manifest's
regex set can be inspected on its own. The probe-validation loop
computes in_hooks/in_config independently via a new matches_any_regex()
helper. Probes carry a new third heredoc field, `shared` or
`hooks-only`: `shared` probes (a file shape genuinely covered by both
manifests, e.g. plugins/demo/.apm/agents/demo.agent.md) must agree
between the two or the check now fails with a drift error;
`hooks-only` probes (a Copilot .agent.md living outside this repo's
own plugins/.apm/ layout, and the legacy bare-`.md`-under-agents/ shape
kept only to exercise a distinct .vale.ini glob section in isolation)
are exempt, since .pre-commit-hooks.yaml is deliberately broader there
by design. The original "matches no regex in either manifest"
staleness check is unchanged.

Added case 11b to tests/test-check-vale-style-sync.sh: narrows a
fixture's local config regex further while leaving
.pre-commit-hooks.yaml untouched, and asserts the check now flags it.
Confirmed red against the pre-fix script before applying the fix.

Refs: #95
2026-08-13 22:07:22 +00:00
925f04acdb fix(agentsmd-audit): scope secrets-scanner placeholder allowlist to matched token
validate-secrets.sh checked the placeholder allowlist regex against the
whole line before running any secret-pattern regex. An unrelated
placeholder-looking token anywhere on the line (e.g. "example" or
"your-token-here" in a trailing comment) suppressed detection of a real
credential earlier on the same line. Scope the allowlist check to the
matched secret-candidate substring only, which the per-match re-check
already did downstream but the whole-line pre-check short-circuited
before it ever ran.

Extend validate-secrets.bats with a case proving a real AWS-style key is
still caught when a placeholder token sits elsewhere on the line.
Regenerate the flat-mirror copy at
plugins/core/skills/agentsmd-audit/scripts/validate-secrets.sh via
scripts/sync-plugin-content.sh --all per ADR-0016.
2026-08-13 21:56:28 +00:00
c6490096da fix(kyberforge): scope check-plugin-content-sync to sync-relevant paths
The pre-push hook ran a full `apm pack` + directory diff across every
plugin on every push (always_run: true), even for changes that could
never affect the sync (e.g. a README edit). Add a files: regex scoped
to what scripts/sync-plugin-content.sh actually consumes -- each
plugin's apm.yml, .mcp.json (read by reinject_mcp_servers), .apm/**
(the sync source), the flat mirror output dirs themselves (agents/,
skills/, commands/, instructions/, extensions/, hooks.json -- so a
direct edit to compiled output is still caught as drift), the
marketplace.json --all reads to build the plugin list, and the sync
script itself -- and drop always_run now that files: covers every
input path.
2026-08-13 21:55:53 +00:00
20 changed files with 565 additions and 179 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -1,3 +0,0 @@
{
"hooks": {}
}

View File

@@ -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

View File

@@ -1,3 +0,0 @@
{
"hooks": {}
}

View File

@@ -1,3 +0,0 @@
{
"hooks": {}
}

View File

@@ -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

View File

@@ -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
# Strip the surrounding YAML quotes; the regex itself never carries them.
raw="${raw%\'}"; raw="${raw#\'}"
raw="${raw%\"}"; raw="${raw#\"}"
printf '%s\n' "$raw"
done
' "$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
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
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"
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
View 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
}

View 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
}

View File

@@ -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,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%/}")"

View File

@@ -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 ==="

View File

@@ -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

View File

@@ -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 ]]

View File

@@ -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 ---

View File

@@ -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

View File

@@ -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 ---

View File

@@ -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 ]]

View File

@@ -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 "