Files
holocron/scripts/check-vale-style-sync.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

202 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Kyberforge's Vale prefilter is duplicated into skill-audit and agent-audit's own
# scripts/assets (per plugins/kyberforge/.apm/skills/skill-author/references/deployment-modes.md's
# no-cross-skill-path rule: a plugin's cache-install copy only includes each skill's own files).
# agent-audit's copy is canonical — it's the superset (Kyberforge + KyberforgeCopilot) that the
# repo root's own pre-commit hook and .pre-commit-hooks.yaml both consume. This fails the build
# if skill-audit's copy has drifted from it, since nothing else would catch a rule fix landing in
# only one of the two. Run from repo root or pass REPO_ROOT as arg.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# A nonexistent REPO_ROOT must fail loudly, not fall through to the "neither
# copy present" no-op below — that guard exists for a repo that legitimately
# has no kyberforge plugin installed, not for a typo'd or stale path, and a
# clean exit 0 here would read as "checked, in sync" when nothing ran at all.
if [[ ! -d "$REPO_ROOT" ]]; then
echo "Vale style sync check failed: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
exit 1
fi
# Absolutized because the glob probe below `cd`s into a scratch tree, where a
# relative --config path would stop resolving.
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
FAIL=0
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit"
AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit"
if [[ ! -d "$SKILL_AUDIT" && ! -d "$AGENT_AUDIT" ]]; then
exit 0
fi
# Exactly one present is drift, not absence: the missing copy can't be in sync
# with the surviving one, and treating it as a no-op is how a deleted or
# renamed copy would slip through silently.
if [[ ! -d "$SKILL_AUDIT" ]]; then
echo "Vale style sync check failed: $AGENT_AUDIT exists but $SKILL_AUDIT does not — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy." >&2
exit 1
fi
if [[ ! -d "$AGENT_AUDIT" ]]; then
echo "Vale style sync check failed: $SKILL_AUDIT exists but $AGENT_AUDIT does not — agent-audit holds the canonical copy, so restore it before syncing." >&2
exit 1
fi
if ! diff -q "$SKILL_AUDIT/scripts/vale-wrap.sh" "$AGENT_AUDIT/scripts/vale-wrap.sh" >/dev/null 2>&1; then
err "scripts/vale-wrap.sh differs between skill-audit and agent-audit"
fi
if ! diff -rq "$SKILL_AUDIT/assets/vale/styles/Kyberforge" "$AGENT_AUDIT/assets/vale/styles/Kyberforge" >/dev/null 2>&1; then
err "assets/vale/styles/Kyberforge differs between skill-audit and agent-audit"
fi
# --- .vale.ini coverage ------------------------------------------------------
# The two .vale.ini files are deliberately NOT identical — agent-audit's carries
# an extra [**/*.agent.md] section and the KyberforgeCopilot style — so they
# cannot be diffed like the styles above. Nothing else in the repo read them at
# all, and that is what let a one-character glob typo silently disable the
# prefilter for a whole file type: the hook still MATCHES the file via its
# `files:` regex, so pre-commit reports neither `Skipped` nor an error; vale
# lints zero files, prints `0 errors ... in 1 file` and exits 0, and the hook
# shows `Passed`. So check the parts that must hold in both, not equality.
SKILL_INI="$SKILL_AUDIT/assets/vale/.vale.ini"
AGENT_INI="$AGENT_AUDIT/assets/vale/.vale.ini"
for ini in "$SKILL_INI" "$AGENT_INI"; do
rel_ini="${ini#"$REPO_ROOT"/}"
if [[ ! -f "$ini" ]]; then
err "$rel_ini is missing — without it vale falls back to an upward config search and lints with whatever it finds"
continue
fi
# StylesPath is resolved relative to the .vale.ini, which is the only reason
# the bundled styles are found from a consuming repo's clone prefix.
if ! grep -Eq '^[[:space:]]*StylesPath[[:space:]]*=[[:space:]]*styles[[:space:]]*$' "$ini"; then
err "$rel_ini has no 'StylesPath = styles' — the bundled styles/ directory would not be found"
fi
# Matches `Kyberforge` as a whole name, so `KyberforgeCopilot` alone does not
# satisfy it. Avoids \b, which is a GNU grep extension.
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*Kyberforge([[:space:],]|$)' "$ini"; then
err "$rel_ini has no section whose BasedOnStyles names Kyberforge — every rule the audit prefilters on lives in that style"
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.
#
# 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.
HOOK_REGEX_CACHE_KEYS=()
HOOK_REGEX_CACHE_VALS=()
hook_file_regexes() {
local skill="$1" manifest raw result idx=0
while [[ $idx -lt ${#HOOK_REGEX_CACHE_KEYS[@]} ]]; do
if [[ "${HOOK_REGEX_CACHE_KEYS[$idx]}" == "$skill" ]]; 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
awk -v skill="$skill" '
function flush() {
if (entry ~ skill "/scripts/vale-wrap.sh" && files != "") print files
entry = ""; files = ""
}
/^[ \t]*-[ \t]*id:/ { flush() }
/^[ \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
)"
HOOK_REGEX_CACHE_KEYS[${#HOOK_REGEX_CACHE_KEYS[@]}]="$skill"
HOOK_REGEX_CACHE_VALS[${#HOOK_REGEX_CACHE_VALS[@]}]="$result"
printf '%s' "$result"
}
# 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
# whose glob matches but whose BasedOnStyles lost Kyberforge fails too: it would
# lint the file and report nothing.
vale_flags_path() {
local cfg="$1" rel="$2" tmp out
tmp="$(mktemp -d)"
mkdir -p "$tmp/$(dirname "$rel")"
{
echo "---"
echo "name: probe"
echo "description: Use when the caller wants a probe that helps with things."
echo "---"
echo ""
echo "Body."
} > "$tmp/$rel"
out="$(cd "$tmp" && vale --config "$cfg" "$rel" 2>&1)" || true
rm -rf "$tmp"
printf '%s\n' "$out" | grep -qF "Kyberforge.VagueWording"
}
VALE_AVAILABLE=true
if ! command -v vale >/dev/null 2>&1; then
VALE_AVAILABLE=false
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
[[ -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
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
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
EOF_PROBE
if [[ $FAIL -gt 0 ]]; then
echo "Vale style sync check failed: $FAIL error(s). For a drifted wrapper or style, agent-audit's copy is canonical — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy, then commit both. A .vale.ini finding is not drift and sync-vale-styles.sh will not fix it: edit that file's own StylesPath, BasedOnStyles or glob sections." >&2
exit 1
fi