Files
holocron/tests/test-vale-hooks-consumer.sh
Defame1297 5e232503c4 feat(kyberforge): execute plugin-to-apm marketplace conversion
Why:
ADR-0015 established that Microsoft APM (apm.yml + .apm/) should replace
this repo's hand-authored plugin.json/marketplace.json model, with those
files becoming compiled output of `apm pack` instead of files edited by
hand via the (now-retired) plugin-author/marketplace-author skills.
Issue #90 was the deferred execution of that decision, gated on #88
(apm tooling) and #89 (apm-native agent-author/skill-author routing).

Implementation notes:
- All six plugins (bin, core, git, gitea, kyberforge, lint) now carry
  apm.yml + .apm/{skills,agents,hooks} as their authoring source. Skills
  moved with a plain git mv (content-identical across targets). Agents
  were re-authored, not moved: per ADR-0016, .apm/agents/*.agent.md
  compiles verbatim to both Claude and Copilot, so plugin-scope agents
  now carry only name/description/model/source_keys -- no tools: field,
  no Claude-only knobs (isolation, maxTurns, effort, memory,
  permissionMode).
- Root apm.yml registers all 7 marketplace packages (6 local plus
  mattpocock-skills as a remote entry) under versioning: per_package,
  matching this repo's existing independent-plugin-versioning practice.
- .claude-plugin/marketplace.json and every plugin's plugin.json are now
  apm-pack-compiled output, verified against the prior hand-maintained
  content: same names/descriptions/versions/licenses/authors, only
  cosmetic serialization differences (JSON key order, owner email vs.
  url, Unicode escaping).
- plugin-author and marketplace-author are retired now that apm-based
  authoring fully replaces their job; kyberforge bumped 1.3.1 -> 1.4.0
  for that removal, and the root marketplace catalog bumped
  0.3.1 -> 0.3.2 to match, per the version-bump convention now
  documented in apm-workflow's reference docs instead of a dedicated
  script (apm has no native version-bump automation).
- Fixed hardcoded pre-.apm/ path assumptions across
  .pre-commit-config.yaml, .pre-commit-hooks.yaml,
  scripts/check-scope-walkup-sync.sh, scripts/sync-vale-styles.sh,
  scripts/check-vale-style-sync.sh, six plugins' root plugin.json
  (stale skills/hooks/agents pointer fields that check-manifests.sh
  validates), and several tests/*.bats and tests/*.sh fixtures --
  including a bats REPO_ROOT relative-path depth bug (10 files, one
  extra .apm/ directory level to walk up) and a vale probe-path
  isolation regression introduced mid-fix.
- Corrected empirically-wrong assumptions surfaced this session in
  apm-workflow/apm-install's own reference docs: `apm marketplace
  package add` does not accept local paths (only owner/repo remote
  shorthand -- local packages are registered by editing apm.yml's
  marketplace.packages[] directly); `apm compile` is a consumer-side
  AGENTS.md/CLAUDE.md generator, not the plugin.json producer, and
  hard-fails on skill/agent-only packages without --clean; `apm plugin
  init <name>` nests a stray subdirectory when run with a positional
  name arg from inside a same-named directory; no native Copilot
  marketplace output profile exists; .mcp.json is merged into the
  compiled plugin.json content-aware and target-scoped, with no
  dependencies.mcp entry needed for simple passthrough; pipx is the
  correct pip fallback on externally-managed Python environments.
- Renamed agent-author's copilot.agent.md template asset to
  copilot.agent.md.template so apm compile's recursive *.agent.md glob
  stops misparsing the placeholder template as a real agent primitive.

Impact:
plugin.json and marketplace.json are compiled artifacts from here on --
editing them by hand is no longer the workflow; edit apm.yml/.apm/ and
run apm pack. CONTEXT.md's Plugin/Plugin marketplace glossary entries
reflect this. ADR-0001 is marked superseded, ADR-0006 moot, and
ADR-0010 updated for the new .apm/agents/ path (project/user scope
unaffected, per ADR-0016). Full local verification: claude plugin
validate --strict on all 6 plugins, apm audit --ci, apm marketplace
check, check-manifests.sh, and the full test suite (165/165 bats,
13/13 shell scripts) all pass clean.

Fixes: #90
Refs: #88, #89
ADR: 0015
ADR: 0016

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub96PyaSRD9BHPktotj1pC
2026-08-12 18:21:24 +00:00

196 lines
7.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Integration test for .pre-commit-hooks.yaml as an EXTERNAL hook repo — the
# contract ADR-0014 exists to provide, and the one thing running pre-commit
# inside this repo can never exercise: `repo: local` makes pre-commit's clone
# prefix equal to the consuming repo's root, so a hook entry that only works
# because those two coincide passes here and hard-fails everywhere else.
# (It did: every argument after entry[0] resolves against the CONSUMING repo,
# so a `--config plugins/.../.vale.ini` argument gave external consumers
# `E100 [--config] Runtime error ... does not exist`, exit 2, on both Vale hooks.)
#
# The hook repo is built from the WORKING TREE, not from HEAD, so an uncommitted
# change to the manifest or the wrapper is what gets tested.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
for bin in pre-commit vale git; do
if ! command -v "$bin" &>/dev/null; then
echo "SKIP: $bin is not installed — cannot stand up a consumer repo"
exit 77
fi
done
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
HOOK_REPO="$WORK/hookrepo"
CONSUMER="$WORK/consumer"
export PRE_COMMIT_HOME="$WORK/pc-home"
mkdir -p "$HOOK_REPO/plugins/kyberforge/.apm/skills" "$HOOK_REPO/scripts"
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$HOOK_REPO/"
cp "$REPO_ROOT/scripts/skill-size-check.sh" "$HOOK_REPO/scripts/"
for skill in skill-audit agent-audit; do
mkdir -p "$HOOK_REPO/plugins/kyberforge/.apm/skills/$skill"
cp -R "$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill/scripts" \
"$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill/assets" \
"$HOOK_REPO/plugins/kyberforge/.apm/skills/$skill/"
done
git -C "$HOOK_REPO" init -q
git -C "$HOOK_REPO" add -A
git -C "$HOOK_REPO" -c user.email=test@example.invalid -c user.name=test commit -qm "hook repo"
HOOK_REV="$(git -C "$HOOK_REPO" rev-parse HEAD)"
# Every hook scopes by filename, so the consumer needs one file of each shape:
# a hook with nothing to match reports `Skipped` and proves nothing. All three
# hooks .pre-commit-hooks.yaml ships are registered — an unregistered one would
# let a regression (a lost `100755` bit, a bad entry path) reach every external
# consumer while this repo's own `repo: local` runs stayed green.
mkdir -p "$CONSUMER/skills/demo" "$CONSUMER/agents"
git -C "$CONSUMER" init -q
cat > "$CONSUMER/.pre-commit-config.yaml" <<EOF
repos:
- repo: file://$HOOK_REPO
rev: $HOOK_REV
hooks:
- id: kyberforge-vale-audit-skill
- id: kyberforge-vale-audit-agent
- id: kyberforge-skill-size-check
EOF
# The two fixtures carry DIFFERENT flagged tokens so an alert can never be
# credited to the hook that did not raise it. Both bodies land mid-sentence in a
# folded block scalar that still spans two physical lines, which is the
# flattening the wrapper exists to do.
write_fixtures() {
local skill_body="$1"
local agent_body="${2:-$1}"
cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF
---
name: demo
description: >
Use when the caller wants a demonstration skill $skill_body across two
physical lines of one folded block scalar.
---
Body.
EOF
cat > "$CONSUMER/agents/demo.md" <<EOF
---
name: demo
description: >
Use when the caller wants a demonstration agent $agent_body across two
physical lines of one folded block scalar.
---
Body.
EOF
git -C "$CONSUMER" add -A
}
# Vale prints each linted path as its own header line with that file's alerts
# indented beneath it, so an alert belongs to the nearest preceding path line.
# Reads a hook log on stdin and prints only the alert lines filed under `$1`.
# The `sed` strips vale's ANSI colouring, which it emits into pre-commit's pipe
# too, so the header lines compare as plain paths.
alerts_for() {
sed $'s/\033\\[[0-9;]*m//g' | awk -v want="$1" '
/^[^[:space:]].*\.md$/ { cur = $0; next }
/^[[:space:]]*[0-9]+:[0-9]+[[:space:]]/ { if (cur == want) print }
'
}
# --- 1. Each Vale hook resolves its config and gates its own file shape ---
# Asserted per hook, against that hook's own fixture path and its own token. An
# aggregate alert count over both hooks' combined output does not prove this:
# one fixture description carries every flagged token, so ONE working hook
# already clears a `>= 2` threshold. And a hook whose .vale.ini globs match
# nothing reaches neither of the guards below — it still MATCHES the file via
# its `files:` regex, so pre-commit does not report `Skipped`; vale simply lints
# nothing, prints `0 errors ... in 1 file` and exits 0, and the hook shows
# `Passed`. Attribution is the only thing that catches it.
echo ""
echo "--- each Vale hook flags its own fixture in an external consumer repo ---"
write_fixtures "that helps with things" "that will utilize things"
while IFS='|' read -r HOOK_ID FIXTURE TOKEN; do
[[ -n "$HOOK_ID" ]] || continue
LOG="$WORK/$HOOK_ID.log"
set +e
(cd "$CONSUMER" && pre-commit run "$HOOK_ID" --all-files > "$LOG" 2>&1)
RC_HOOK=$?
set -e
if grep -q "does not exist" "$LOG"; then
fail "$HOOK_ID hard-errored on a path resolved against the consumer repo (E100) — the bug this test guards against"
sed 's/^/ /' "$LOG"
elif grep -q "Skipped" "$LOG"; then
fail "$HOOK_ID matched no files, so it proved nothing"
sed 's/^/ /' "$LOG"
elif [[ $RC_HOOK -eq 0 ]]; then
fail "$HOOK_ID passed $FIXTURE despite its flagged '$TOKEN' — a .vale.ini glob matching nothing lints zero files and exits 0"
sed 's/^/ /' "$LOG"
elif alerts_for "$FIXTURE" < "$LOG" | grep -qF "'$TOKEN'"; then
pass "$HOOK_ID flattens $FIXTURE and flags its '$TOKEN' in a consumer repo"
else
fail "$HOOK_ID failed, but no alert quoting '$TOKEN' was filed under $FIXTURE"
sed 's/^/ /' "$LOG"
fi
done <<'EOF'
kyberforge-vale-audit-skill|skills/demo/SKILL.md|helps with
kyberforge-vale-audit-agent|agents/demo.md|utilize
EOF
# --- 2. Clean files pass — the hooks gate, they don't just always fail ---
echo ""
echo "--- all three hooks pass clean files in an external consumer repo ---"
write_fixtures "of the packaged hook contract"
set +e
(cd "$CONSUMER" && pre-commit run --all-files > "$WORK/clean.log" 2>&1)
RC_CLEAN=$?
set -e
if grep -q "Skipped" "$WORK/clean.log"; then
fail "a hook matched no files on the clean run, so it proved nothing"
sed 's/^/ /' "$WORK/clean.log"
elif [[ $RC_CLEAN -eq 0 ]]; then
pass "all three hooks exit 0 on clean files"
else
fail "hooks failed on clean files (rc=$RC_CLEAN)"
sed 's/^/ /' "$WORK/clean.log"
fi
# --- 3. The size hook gates too. It ran clean above, which is what proves it
# is executable and its entry path resolves; this half proves it still fails a
# file that breaks the ceiling rather than passing everything. ---
echo ""
echo "--- kyberforge-skill-size-check fails an oversized SKILL.md in an external consumer repo ---"
mkdir -p "$CONSUMER/skills/oversized"
{
echo "---"
echo "name: oversized"
echo "description: Use when the caller wants an oversized fixture."
echo "---"
for ((i = 1; i <= 600; i++)); do
echo "word"
done
} > "$CONSUMER/skills/oversized/SKILL.md"
git -C "$CONSUMER" add -A
set +e
(cd "$CONSUMER" && pre-commit run kyberforge-skill-size-check --all-files > "$WORK/size.log" 2>&1)
RC_SIZE=$?
set -e
if [[ $RC_SIZE -ne 0 ]] && grep -q "500-line ceiling" "$WORK/size.log"; then
pass "kyberforge-skill-size-check exits non-zero and names the ceiling it broke"
else
fail "kyberforge-skill-size-check did not gate an oversized SKILL.md (rc=$RC_SIZE)"
sed 's/^/ /' "$WORK/size.log"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]