Files
holocron/scripts/check-apm-agents-valid.sh
Defame1297 a155af6827 ci: audit every apm package, and validate the agents the validator governs
apm-audit-ci ran against root apm.yml alone, so it audited none of the six
plugin packages, and its description claimed a lockfile/policy/hidden-content
gate while delivering one vacuous check. It now loops all seven manifests, and
the description says only what runs. Proven load-bearing: a malformed dependency
in plugins/lint/apm.yml passed the old root-only entry at exit 0 and passed
apm pack --check-clean too, because that gate never parses plugin dependencies;
the loop catches it and names the file.

policy.fetch_failure_default: block was considered and rejected. apm's org-policy
discovery understands github.com and Azure DevOps; this repo's remote is
self-hosted Gitea, so no policy source is discoverable and the setting makes the
hook exit 1 on every push forever. Fail-closed is right when there is a control
to fail closed on -- a permanently red gate is one people learn to SKIP=, which
is worse than an accurate description.

agent-audit's validate.sh had never run against the four real .apm/agents files
it governs, only against synthetic fixtures. That is why an amended ADR-0016 and
a validator that still rejected the field it blessed could disagree unnoticed
until someone ran it by hand. check-apm-agents-valid.sh closes it, deriving the
expected set from git ls-files rather than a count, failing on zero discovered
files, and replaying validate.sh's own reason under each failing filename.

Also makes the pretty-format-json exclude consistently root-anchored: it mixed
(^|/) for five paths with ^ for one, so a nested fixture at
.../.claude-plugin/plugin.json was silently exempt from formatting.

Pre-push goes 12 repo-defined to 13, 14 total to 15; AGENTS.md's counts, hook
list and offline-skip note are updated to match. The new hook needs no network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 14:20:33 +00:00

162 lines
7.1 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Run agent-audit's validate.sh over every REAL plugin-scope agent file in this
# repo (plugins/*/.apm/agents/*.agent.md).
#
# Why this exists: validate.sh was previously exercised only by
# scripts/check-scope-walkup-sync.sh, and only against synthetic fixtures built
# in mktemp trees. It had never once run against the four agent files it
# actually governs. That is how ADR-0016 could be amended to bless a
# `disallowedTools` frontmatter field while validate.sh's allowlist still
# rejected it -- the spec and its enforcer disagreed, every gate stayed green,
# and the contradiction only surfaced when someone ran the validator by hand.
#
# A validator that checks fixtures but never artifacts is the same
# green-because-nothing-was-checked shape as a suite that runs on an empty file
# set. This closes it: the artifacts are the input.
#
# Run from repo root, or pass REPO_ROOT as the first argument (tests do).
# Needs no network.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# A nonexistent REPO_ROOT must fail loudly rather than fall through to the
# zero-files floor below with a confusing message -- a typo'd or stale path is a
# different problem from a repo that genuinely has no agents, and the fix
# differs too.
if [[ ! -d "$REPO_ROOT" ]]; then
echo "APM agent validation failed: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
exit 1
fi
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
# The validator's own absence is a hard failure, never a skip. If validate.sh
# moves or is deleted, every assertion below evaporates and the hook would
# otherwise exit 0 having validated nothing -- indistinguishable, from
# pre-commit's silent-on-pass output, from a run where all four agents passed.
if [[ ! -f "$VALIDATE" ]]; then
echo "APM agent validation failed: validator not found at $VALIDATE — this script's path has gone stale, so no agent file was checked. Update it to wherever agent-audit's validate.sh now lives." >&2
exit 1
fi
# validate.sh is a bash wrapper around a heredoc'd python3 program. Without
# python3 it dies with a bare "command not found" per file and no pointer, which
# reads like a validation failure rather than a missing dependency. Fail closed,
# but say which it is.
if ! command -v python3 >/dev/null 2>&1; then
echo "APM agent validation failed: python3 not found on PATH — agent-audit's validate.sh is a python3 program and cannot run. Install python3; this gate does not degrade to a pass." >&2
exit 1
fi
# --- Discover the agent files ---
# `-not -path` mirrors tests/run-bats.sh: worktrees under .claude/ are other
# checkouts of this same repo, not additional content.
AGENT_FILES=()
while IFS= read -r f; do
[[ -n "$f" ]] && AGENT_FILES+=("$f")
done < <(
find "$REPO_ROOT/plugins" -type f -name '*.agent.md' \
-path '*/.apm/agents/*' \
-not -path '*/.claude/worktrees/*' \
2>/dev/null | sort
)
# --- Derive the EXPECTED set from the index, not from a hardcoded count ---
# Same reasoning as tests/run-bats.sh: a magic number goes stale the moment a
# plugin gains or loses an agent, and slack in a floor is exactly where a
# silently-deleted file hides. `git ls-files` needs no maintenance -- a newly
# `git add`ed agent file joins the expectation immediately.
#
# Direction matters, and it is the same direction run-bats.sh uses: every
# TRACKED file must have been discovered, but a discovered file need not be
# tracked. An untracked new agent file is ordinary work in progress (and is
# still validated below), while a file that vanished from the worktree without
# leaving the index is an accident and fails here. A deliberate `git rm` leaves
# the index, so intentional removal passes.
#
# The exact-equality check on --show-toplevel keeps this off the mktemp fixture
# trees in tests/test-check-apm-agents-valid.sh, which resolve no worktree. That
# degradation is announced rather than silent, and the zero-file floor below is
# unconditional regardless.
EXPECTED_FILES=()
DERIVED=false
GIT_TOPLEVEL="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -n "$GIT_TOPLEVEL" && "$GIT_TOPLEVEL" == "$REPO_ROOT" ]]; then
DERIVED=true
while IFS= read -r f; do
[[ -n "$f" ]] && EXPECTED_FILES+=("$REPO_ROOT/$f")
done < <(
git -C "$REPO_ROOT" ls-files -- 'plugins/*/.apm/agents/*.agent.md' \
| grep -Ev '(^|/)\.claude/worktrees/' \
| sort || true
)
else
echo "Note: $REPO_ROOT is not a git worktree root, so the expected agent file set could not be derived from the index — only the zero-file floor below applies" >&2
fi
if [[ "$DERIVED" == true && ${#EXPECTED_FILES[@]} -gt 0 ]]; then
MISSING=()
for expected in ${EXPECTED_FILES[@]+"${EXPECTED_FILES[@]}"}; do
found=false
for actual in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
if [[ "$actual" == "$expected" ]]; then
found=true
break
fi
done
[[ "$found" == true ]] || MISSING+=("${expected#"$REPO_ROOT"/}")
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "APM agent validation failed: ${#MISSING[@]} of ${#EXPECTED_FILES[@]} tracked agent file(s) were not discovered under $REPO_ROOT — they were deleted without being removed from the index, or this script's search path no longer reaches them:" >&2
for m in ${MISSING[@]+"${MISSING[@]}"}; do
echo " $m" >&2
done
exit 1
fi
fi
# Unconditional floor, separate from the derived check above: a tree with
# nothing tracked (a tarball export, a fresh scaffold, a moved plugins/ root)
# still must not validate an empty set and call it green. Zero files is an
# error, not a pass -- that is the entire defect this script was written to
# close, one level up.
if [[ ${#AGENT_FILES[@]} -eq 0 ]]; then
echo "APM agent validation failed: found 0 plugin-scope agent file(s) under $REPO_ROOT/plugins — the search path is wrong or every .apm/agents/ directory has been emptied. Zero files is never a pass." >&2
exit 1
fi
# --- Validate ---
FAIL=0
FAILED_FILES=()
for f in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
rel="${f#"$REPO_ROOT"/}"
# validate.sh prints its FAIL lines on stdout and its own errors on stderr;
# both are captured and replayed under the filename so the reason travels with
# the file that caused it. pre-commit shows a failing hook's output verbatim,
# so this is what a developer reads.
out=""
rc=0
out="$(bash "$VALIDATE" "$f" 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
FAIL=1
FAILED_FILES+=("$rel")
echo "FAIL: $rel (validate.sh exit $rc)" >&2
if [[ -n "$out" ]]; then
printf '%s\n' "$out" | sed 's/^/ /' >&2
else
echo " (validate.sh produced no output — see its exit code above; 2 means script error, e.g. a missing references/field-inventory.md)" >&2
fi
fi
done
if [[ "$FAIL" -ne 0 ]]; then
echo "" >&2
echo "APM agent validation failed: ${#FAILED_FILES[@]} of ${#AGENT_FILES[@]} agent file(s) did not pass agent-audit's validate.sh." >&2
exit 1
fi
echo "APM agent validation passed: ${#AGENT_FILES[@]} plugin-scope agent file(s) validated against agent-audit's validate.sh."