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
162 lines
7.1 KiB
Bash
Executable File
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."
|