#!/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/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/skills/skill-audit" AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/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/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 <&2 exit 1 fi